Repository: awesome-academy/naitei_ruby_10_02_2025_hotel_management
Pull request #35: admin reviews list
Author: login: tungnx-3642
Created: 2025-04-17T05:29:46Z | Merged: 2025-04-17T10:31:27Z

## Related Tickets
- [[Admin/Advanced] Duyệt các review của user](https://edu-redmine.sun-asterisk.vn/issues/86060?issue_count=9&issue_position=6&next_issue_id=86059&prev_issue_id=86063)

## WHAT (optional)
- Change number items `completed/total` in admin page.

## HOW
- I edit js file, inject not_vary_normal items in calculate function.

## WHY (optional)
- Because in previous version - number just depends on `normal` items. But in new version, we have `state` and `confirm_state` depends on both `normal` + `not_normal` items.

## Evidence (Screenshot or Video)
![image](https://github.com/user-attachments/assets/2bf489a3-bc56-47f0-bc7f-2f34af50d7a9)
![Animation](https://github.com/user-attachments/assets/380d9de2-d714-46eb-99e9-2619f3057695)



Diff:
diff --git a/app/assets/stylesheets/_variables.scss b/app/assets/stylesheets/_variables.scss
index ce1932f..8d87f9c 100644
--- a/app/assets/stylesheets/_variables.scss
+++ b/app/assets/stylesheets/_variables.scss
@@ -46,3 +46,5 @@ $qr-text: #444;
 $checkout-bg: #f4f4f4;
 $checkout-text: #333;
 $warning-color: #f39c12;
+$score-color: #f5c518;
+$shadow-color: rgba(0,0,0,0.05);
diff --git a/app/assets/stylesheets/admin.scss b/app/assets/stylesheets/admin.scss
index 1ab4371..eae7383 100644
--- a/app/assets/stylesheets/admin.scss
+++ b/app/assets/stylesheets/admin.scss
@@ -73,6 +73,7 @@
 
 .header-nav:hover ul {
   display: block;
+  list-style-type: none;
 }
 
 .admin-sidebar {
@@ -349,3 +350,58 @@
     }
   }
 }
+
+.filter {
+  display: flex;
+  gap: 10px;
+  margin-bottom: 20px;
+  margin-top: 10px;
+  align-items: center;
+
+  p {
+    margin: 0;
+  }
+
+  input[type="date"] {
+    width: 150px;
+  }
+}
+
+.review {
+  border: 1px solid $table-border;
+  padding: 16px;
+  margin-bottom: 16px;
+  border-radius: 6px;
+  box-shadow: 0 2px 4px $shadow-color;
+  background-color: $white-color;
+}
+
+.review-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 8px;
+}
+
+.review-user {
+  font-size: 18px;
+  font-weight: bold;
+  margin: 0;
+}
+
+.review-date {
+  font-size: 14px;
+  color: $text-light;
+}
+
+.review-score {
+  color: $score-color; 
+  font-size: 18px;
+  margin-bottom: 8px;
+}
+
+.review-content {
+  font-size: 16px;
+  color: $text-color;
+  margin: 0;
+}
diff --git a/app/controllers/admin_reviews_controller.rb b/app/controllers/admin_reviews_controller.rb
new file mode 100644
index 0000000..98bea9a
--- /dev/null
+++ b/app/controllers/admin_reviews_controller.rb
@@ -0,0 +1,12 @@
+class AdminReviewsController < BaseAdminController
+  def index
+    @start_date = get_date :start_date
+    @end_date = get_date :end_date
+    @reviews = Review.includes(:user).from_to(@start_date, @end_date)
+  end
+
+  private
+  def get_date input
+    params[input].present? ? Date.parse(params[input]) : Time.zone.today
+  end
+end
diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb
index b0b1af3..16df0ae 100644
--- a/app/controllers/dashboard_controller.rb
+++ b/app/controllers/dashboard_controller.rb
@@ -1,15 +1,24 @@
 class DashboardController < BaseAdminController
+  before_action :load_counts, only: :index
+  before_action :set_date_params, only: :index
+
   def index
+    @bills = Bill.paid_in_month(@month, @year)
+                 .with_room_type(params[:room_type])
+  end
+
+  private
+  def load_counts
     @user_count = User.count
     @room_count = Room.count
     @room_type_count = RoomType.count
     @bill_count = Bill.count
     @service_count = Service.count
     @request_count = Request.count
-    @month = params[:month].to_i || Time.zone.today.month
-    @year = params[:year].to_i || Time.zone.today.year
-    @room_type_id = params[:room_type]
-    @bills = Bill.paid_in_month(@month, @year)
-                 .with_room_type(params[:room_type])
+  end
+
+  def set_date_params
+    @month = params[:month].blank? ? Time.zone.today.month : params[:month].to_i
+    @year = params[:year].blank? ? Time.zone.today.year : params[:year].to_i
   end
 end
diff --git a/app/controllers/requests_controller.rb b/app/controllers/requests_controller.rb
index 741119f..459ff17 100644
--- a/app/controllers/requests_controller.rb
+++ b/app/controllers/requests_controller.rb
@@ -2,6 +2,7 @@ class RequestsController < BaseAdminController
   before_action :get_request, except: %i(index)
   before_action :check_checkinable, only: %i(checkin checkin_submit)
   before_action :check_checkoutable, only: %i(checkout checkout_submit)
+  before_action :check_payable_request, only: %i(bill_pay)
 
   def index
     @requests = Request.includes(:user, :room_type)
@@ -58,9 +59,14 @@ def checkout
   end
 
   def checkout_submit
-    @bill = @request.build_bill(bill_params)
-    @bill.assign_attributes(user: @request.user, pay_at: Time.current)
-    @bill.total = calculate_total @bill
+    services_fields = bill_params[:bills_services_attributes].values
+    @bill = @request.build_bill(
+      user: @request.user,
+      total: calculate_total(services_fields),
+      bills_services_attributes: services_fields.reject do |s|
+        s[:service_id].blank?
+      end
+    )
     if @bill.save
       on_checkout_success
     else
@@ -69,6 +75,17 @@ def checkout_submit
     end
   end
 
+  def bill_pay
+    @request.bill.pay_at = Time.zone.now
+    if @request.bill.save
+      @request.update(status: Settings.requests.status.finished)
+      flash[:success] = t "msg.bill_paid"
+    else
+      flash[:error] = t "msg.bill_pay_failed"
+    end
+    redirect_to :requests
+  end
+
   private
   def bill_params
     params.require(:bill).permit(
@@ -115,13 +132,20 @@ def check_checkoutable
     redirect_to requests_path
   end
 
-  def calculate_total bill
-    services = Service.where(id: bill.bills_services.map(&:service_id))
-    service_total = bill.bills_services.sum do |bs|
-      service = services.find_by id: bs.service_id
+  def check_payable_request
+    return if @request.checkouted?
+
+    flash[:error] = t "msg.invalid_request_status"
+    redirect_to requests_path
+  end
+
+  def calculate_total services_fields
+    services = Service.where(id: services_fields.map{|s| s[:service_id]})
+    service_total = services_fields.sum do |sf|
+      service = services.find_by id: sf[:service_id]
       next 0 unless service
 
-      service.price * bs.quanity
+      service.price * sf[:quanity].to_i
     end
     service_total + @request.room_total_price
   end
diff --git a/app/helpers/admin_reviews_helper.rb b/app/helpers/admin_reviews_helper.rb
new file mode 100644
index 0000000..2ad7116
--- /dev/null
+++ b/app/helpers/admin_reviews_helper.rb
@@ -0,0 +1,7 @@
+module AdminReviewsHelper
+  def render_review_stars score
+    full_stars = "★" * score.to_i
+    empty_stars = "☆" * (5 - score.to_i)
+    content_tag(:div, full_stars + empty_stars, class: "review-score")
+  end
+end
diff --git a/app/models/bill.rb b/app/models/bill.rb
index fb53b10..1e3dd38 100644
--- a/app/models/bill.rb
+++ b/app/models/bill.rb
@@ -20,4 +20,8 @@ class Bill < ApplicationRecord
         .where(requests: {room_type_id: room_type_id})
     end
   }
+
+  def status
+    pay_at.present? ? :paid : :unpay
+  end
 end
diff --git a/app/models/request.rb b/app/models/request.rb
index eb7fc7b..6277162 100644
--- a/app/models/request.rb
+++ b/app/models/request.rb
@@ -31,16 +31,6 @@ class Request < ApplicationRecord
     with_room_type(room_type_id).active_statuses.overlapping_date(date)
   }
 
-  private
-
-  def set_deposit_amount_if_deposited
-    self.deposit_amount = deposited? ? total_price.to_f * 0.5 : 0
-  end
-
-  def room_total_price
-    quantity * room_type.price * (checkout_date - checkin_date).to_i
-  end
-
   def send_request_checkined_mail
     RequestMailer.checkin_request(self).deliver_now
   end
@@ -52,4 +42,13 @@ def send_request_denied_mail
   def generate_token
     self.token ||= SecureRandom.hex(10)
   end
+  
+  def room_total_price
+    quantity * room_type.price * (checkout_date - checkin_date).to_i
+  end
+
+  private
+  def set_deposit_amount_if_deposited
+    self.deposit_amount = deposited? ? total_price.to_f * 0.5 : 0
+  end
 end
diff --git a/app/models/review.rb b/app/models/review.rb
index c6346ab..9aa393a 100644
--- a/app/models/review.rb
+++ b/app/models/review.rb
@@ -2,4 +2,9 @@ class Review < ApplicationRecord
   belongs_to :user
 
   validates :content, presence: true
+  scope :from_to, lambda {|start_date, end_date|
+    if start_date.present? && end_date.present?
+      where(created_at: start_date.beginning_of_day..end_date.end_of_day)
+    end
+  }
 end
diff --git a/app/views/admin_reviews/_review.html.erb b/app/views/admin_reviews/_review.html.erb
new file mode 100644
index 0000000..759090e
--- /dev/null
+++ b/app/views/admin_reviews/_review.html.erb
@@ -0,0 +1,12 @@
+<div class="review">
+  <div class="review-header">
+    <h3 class="review-user"><%= review.user.usename %></h3>
+    <span class="review-date"><%= review.created_at.strftime("%B %d, %Y") %></span>
+  </div>
+
+  <%= render_review_stars review.score %>
+
+  <p class="review-content">
+    <%= review.content %>
+  </p>
+</div>
diff --git a/app/views/admin_reviews/index.html.erb b/app/views/admin_reviews/index.html.erb
new file mode 100644
index 0000000..88c46ed
--- /dev/null
+++ b/app/views/admin_reviews/index.html.erb
@@ -0,0 +1,17 @@
+<% provide :title, t(".reviews") %>
+<div class="container">
+  <%= form_with url: reviews_path, method: :get, class: "filter" do |f| %>
+    <p><%= t ".start_date" %></p>
+    <%= f.date_field :start_date, value: @start_date, class: "form-control", max: @end_date %>
+    <p><%= t ".end_date" %></p>
+    <%= f.date_field :end_date, value: @end_date, class: "form-control", max: Date.today %>
+    <%= f.submit t(".filter"), class: "btn btn-primary" %>
+  <% end %>
+  <% if @reviews.any? %>
+    <%= render partial: "review", collection: @reviews %>
+  <% else %>
+    <div class="alert alert-info">
+      <%= t ".no_reviews" %>
+    </div>
+  <% end %>
+</div>
diff --git a/app/views/requests/checkin.html.erb b/app/views/requests/checkin.html.erb
index 6eb96fe..3bc135a 100644
--- a/app/views/requests/checkin.html.erb
+++ b/app/views/requests/checkin.html.erb
@@ -26,7 +26,7 @@
     </table>
     <div class="action">
         <%= submit_tag t("checkin"), class: "btn btn-primary" %>
-         <%= link_to "Deny", deny_request_url(@request), data: { turbo_stream: true } %>
+         <%= link_to t(".deny"), deny_request_url(@request), data: { turbo_stream: true } %>
     </div>
   <% end %>
 </div>
diff --git a/app/views/requests/show.html.erb b/app/views/requests/show.html.erb
index 3a6f0c9..aeacc3d 100644
--- a/app/views/requests/show.html.erb
+++ b/app/views/requests/show.html.erb
@@ -5,6 +5,7 @@
     <h3>
       <%= bootstrap_icon "bookmark-dash-fill", height: Settings.default.view.sidebar.icon_size, class:"action-logo" %>
       <%= t ".bill_infor" %>
+      <span class="badge"><%= @request.bill.status %></span>
     </h3>
     <table class="table">
         <tr>
@@ -36,5 +37,6 @@
           </td>
         </tr>
     </table>
+    <%= link_to t(".user_paid"), bill_pay_request_path(@request), data: { turbo_method: :patch } %>
   <% end %>
 </div>
diff --git a/app/views/shared/admin/_header.html.erb b/app/views/shared/admin/_header.html.erb
index 367f8ac..5ab095e 100644
--- a/app/views/shared/admin/_header.html.erb
+++ b/app/views/shared/admin/_header.html.erb
@@ -15,8 +15,6 @@
       <p><%= t "admin.layouts.admin" %></p>
     </div>
     <ul>
-      <li><%= link_to "Information", "#" %></li>
-      <li><%= link_to "Settings", "#" %></li>
       <li><%= link_to "Log out", logout_path, method: :delete %></li>
     </ul>
   </div>
diff --git a/app/views/shared/admin/_sidebar.html.erb b/app/views/shared/admin/_sidebar.html.erb
index 0e81a4f..c44113a 100644
--- a/app/views/shared/admin/_sidebar.html.erb
+++ b/app/views/shared/admin/_sidebar.html.erb
@@ -36,5 +36,11 @@
         <%= t "admin.layouts.service" %>
       <% end %>
     </li>
+    <li>
+      <%= link_to reviews_path, class: sidebar_highlight_check("reviews") do %>
+        <%= bootstrap_icon "chat-dots-fill", height: Settings.default.view.sidebar.icon_size, class:"action-logo" %>
+        <%= t "admin.layouts.reviews" %>
+      <% end %>
+    </li>
   </ul>
 </aside>
diff --git a/config/locales/en.yml b/config/locales/en.yml
index aef1d6c..b9dc8ff 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -58,6 +58,7 @@ en:
       request: "Request"
       service: "Service"
       search: "Search"
+      reviews: "Reviews"
   room_types:
     index:
       room_type_management: "Room type managment"
@@ -121,6 +122,8 @@ en:
     user_deactivated_failed: "User deactivation failed!"
     checkout_success: "Checkout successful!"
     checkout_failed: "Checkout failed!"
+    bill_paid: "Bill paid successfully!"
+    bill_paid_failed: "Bill payment failed!"
   menu:
     overview: "Overview"
     room_list: "Room List"
@@ -161,6 +164,7 @@ en:
       phone: "Phone"
       Email: "Email"
       request_infor: "Request's information"
+      deny: "Deny"
     deny:
       deny_request: "Deny request"
       deny_reason: "Deny reason"
@@ -190,6 +194,7 @@ en:
       total_price: "Total price"
       room_total_price: "Room total price"
       total: "Total"
+      user_paid: "User paid"
   room: "Room"
   rooms:
     room_management: "Room Management"
@@ -325,6 +330,13 @@ en:
   time:
     formats:
       default: "%H:%M %d/%m/%Y"
+  admin_reviews:
+    index:
+      reviews: "Reviews"
+      start_date: "Start date"
+      end_date: "End date"
+      filter: "Filter"
+      no_reviews: "No reviews"
   request_mailer:
     checkin_request:
       subject: "Booking confirmation"
diff --git a/config/locales/vi.yml b/config/locales/vi.yml
index 89cbaf0..3d40594 100644
--- a/config/locales/vi.yml
+++ b/config/locales/vi.yml
@@ -59,6 +59,7 @@ vi:
       request: "Yêu cầu đặt phòng"
       service: "Dịch vụ"
       search: "Tìm kiếm"
+      reviews: "Đánh giá"
   room_types:
     index:
       room_type_management: "Quản lý loại phòng"
@@ -123,6 +124,8 @@ vi:
     user_deactivated_failed: "Vô hiệu hóa người dùng thất bại!"
     checkout_success: "Trả phòng thành công!"
     checkout_failed: "Trả phòng thất bại!"
+    bill_paid: "Đã thanh toán hóa đơn!"
+    bill_paid_failed: "Thanh toán hóa đơn thất bại!"
   menu:
     overview: "Tổng quan"
     room_list: "Danh sách phòng"
@@ -196,6 +199,8 @@ vi:
       total_service_price: "Tổng tiền dịch vụ"
       total_room_price: "Tổng tiền phòng"
       submit: "Gửi"
+    checkin:
+      deny: "Từ chối yêu cầu"
     deny:
       deny_request: "Từ chối yêu cầu đặt phòng"
       deny_reason: "Lí do từ chối"
@@ -208,6 +213,7 @@ vi:
       total_price: "Tổng tiền"
       room_total_price: "Tổng tiền phòng"
       total: "Tổng cộng"
+      user_paid: "Đã được thanh toán"
   room: "Phòng"
   rooms:
     room_management: "Quản lý phòng"
@@ -289,7 +295,6 @@ vi:
   day: "ngày"
   request_successfull: "Đặt phòng thành công!"
   request_error : "Lỗi đặt phòng"
-  request:
   details_title: "Chi tiết đặt phòng"
   checkin_date: "Ngày nhận phòng"
   checkout_date: "Ngày trả phòng"
@@ -329,6 +334,13 @@ vi:
   role:
     admin: "Quản trị viên"
     user: "Người dùng"
+  admin_reviews:
+    index:
+      reviews: "Đánh giá"
+      start_date: "Ngày bắt đầu"
+      end_date: "Ngày kết thúc"
+      filter: "Lọc"
+      no_reviews: "Không có đánh giá nào"
   request_mailer:
     checkin_request:
       subject: "Đã checkin đơn đặt phòng"
@@ -346,6 +358,8 @@ vi:
       reason: "Lí do từ chối"
   
   date:
+    order: ["day", "month", "year"]
+    month_names: [~, "Tháng 1", "Tháng 2", "Tháng 3", "Tháng 4", "Tháng 5", "Tháng 6", "Tháng 7", "Tháng 8", "Tháng 9", "Tháng 10", "Tháng 11", "Tháng 12"]
     formats:
       default: "%d/%m/%Y"
   time:
diff --git a/config/routes.rb b/config/routes.rb
index a3db96a..51e863c 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -12,6 +12,7 @@
     get "sessions/destroy"
     scope "admin" do
       get "dashboard", to: "dashboard#index"
+      get "reviews", to: "admin_reviews#index"
       resources :room_types do
         delete "images/:image_id", to: "room_types#destroy_image", as: "image"
       end
@@ -24,6 +25,7 @@
           post "deny/submit", to: "requests#deny_submit"
           get "checkout"
           post "checkout/submit", to: "requests#checkout_submit"
+          patch "bill_pay", to: "requests#bill_pay"
         end
       end
       resources :services
diff --git a/db/seeds.rb b/db/seeds.rb
index 00b40fd..37abc4d 100644
--- a/db/seeds.rb
+++ b/db/seeds.rb
@@ -77,3 +77,11 @@
     price: (n + 1) * 10000
   )
 end
+
+10.times do |n|
+  Review.create!(
+    user: User.find((n % 10) + 1),
+    score: rand(1..5),
+    content: Faker::Lorem.sentence(word_count: 10)
+  )
+end


Reviews:
body: cmt
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-17T06:23:28Z
body: cmt
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-17T09:32:28Z

Review comments:
diff_hunk: @@ -0,0 +1,17 @@
+<% provide :title, t(".reviews") %>
+<div class="container">
+  <%= form_with url: reviews_path, method: :get, class: "filter" do |f| %>
+    <p><%= t ".start_date" %></p>
+    <%= f.date_select :start_date, default: @start_date, class: "form-control" %>
+    <p><%= t ".end_date" %></p>
+    <%= f.date_select :end_date, default: @end_date, class: "form-control" %>
path: app/views/admin_reviews/index.html.erb
body: Dùng cách khác
created_at: 2025-04-17T06:23:23Z
author_association: NONE
diff_hunk: @@ -349,3 +349,58 @@
     }
   }
 }
+
+.filter {
+  display: flex;
+  gap: 10px;
+  margin-bottom: 20px;
+  margin-top: 10px;
+  align-items: center;
+
+  p {
+    margin: 0;
+  }
+
+  input[type="date"] {
+    width: 150px;
+  }
+}
+
+.review {
+  border: 1px solid #ddd;
+  padding: 16px;
+  margin-bottom: 16px;
+  border-radius: 6px;
+  box-shadow: 0 2px 4px rgba(0,0,0,0.05);
+  background-color: #fff;
+}
+
+.review-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 8px;
+}
+
+.review-user {
+  font-size: 18px;
+  font-weight: bold;
+  margin: 0;
+}
+
+.review-date {
+  font-size: 14px;
+  color: #777;
path: app/assets/stylesheets/admin.scss
body: màu 
created_at: 2025-04-17T09:23:04Z
author_association: NONE
diff_hunk: @@ -1,15 +1,25 @@
 class DashboardController < BaseAdminController
+  before_action :load_counts, only: :index
+  before_action :set_date_params, only: :index
+
   def index
+    @room_type_id = params[:room_type]
path: app/controllers/dashboard_controller.rb
body: Có cần thiết ko 
created_at: 2025-04-17T09:23:55Z
author_association: NONE
diff_hunk: @@ -59,8 +60,11 @@ def checkout
 
   def checkout_submit
     @bill = @request.build_bill(bill_params)
path: app/controllers/requests_controller.rb
body: ```suggestion
    @bill = @request.build_bill(bill_params.merge!({user: @request.user, bills_services: @bill.bills_services.reject{|bs| bs.service_id.blank?}, total: calculate_total(@bill)}))
```
created_at: 2025-04-17T09:31:05Z
author_association: NONE
diff_hunk: @@ -0,0 +1,17 @@
+<% provide :title, t(".reviews") %>
+<div class="container">
+  <%= form_with url: reviews_path, method: :get, class: "filter" do |f| %>
+    <p><%= t ".start_date" %></p>
+    <%= f.date_field :start_date, value: @start_date, class: "form-control", max: @end_date %>
+    <p><%= t ".end_date" %></p>
+    <%= f.date_field :end_date, value: @end_date, class: "form-control", max: Date.today %>
+    <%= f.submit t(".filter"), class: "btn btn-primary" %>
+  <% end %>
+  <% if @reviews.length > 0 %>
path: app/views/admin_reviews/index.html.erb
body: ```suggestion
  <% if @reviews.any? %>
```
created_at: 2025-04-17T09:32:10Z
author_association: NONE

PR comments:
user: login: tungnx-3642
created_at: 2025-04-17T05:29:53Z
body: ready
author_association: CONTRIBUTOR
user: login: tungnx-3642
created_at: 2025-04-17T09:22:16Z
body: ready

author_association: CONTRIBUTOR
user: login: tungnx-3642
created_at: 2025-04-17T10:22:39Z
body: ready
author_association: CONTRIBUTOR