diff --git a/app/controllers/internal_api/v1/reports/accounts_aging_controller.rb b/app/controllers/internal_api/v1/reports/accounts_aging_controller.rb index 7cb754f60b..9f5d029d71 100644 --- a/app/controllers/internal_api/v1/reports/accounts_aging_controller.rb +++ b/app/controllers/internal_api/v1/reports/accounts_aging_controller.rb @@ -5,4 +5,9 @@ def index authorize :report render :index, locals: Reports::AccountsAging::FetchOverdueAmount.process(current_company), status: :ok end + + def download + authorize :report + send_data Reports::AccountsAging::DownloadService.new(params, current_company).process + end end diff --git a/app/javascript/src/apis/reports/accountsAging.ts b/app/javascript/src/apis/reports/accountsAging.ts index f19a568614..de31c40aa2 100644 --- a/app/javascript/src/apis/reports/accountsAging.ts +++ b/app/javascript/src/apis/reports/accountsAging.ts @@ -4,6 +4,13 @@ const path = "/reports/accounts_aging"; const get = () => axios.get(path); -const accountsAgingApi = { get }; +const download = (type, queryParams) => + axios({ + method: "GET", + url: `${path}/download.${type}${queryParams}`, + responseType: "blob", + }); + +const accountsAgingApi = { get, download }; export default accountsAgingApi; diff --git a/app/javascript/src/components/Reports/AccountsAgingReport/index.tsx b/app/javascript/src/components/Reports/AccountsAgingReport/index.tsx index b41702f148..20d7c581ce 100644 --- a/app/javascript/src/components/Reports/AccountsAgingReport/index.tsx +++ b/app/javascript/src/components/Reports/AccountsAgingReport/index.tsx @@ -3,12 +3,14 @@ import React, { useState, useEffect } from "react"; import Logger from "js-logger"; import { useNavigate } from "react-router-dom"; +import accountsAgingApi from "apis/reports/accountsAging"; import Loader from "common/Loader/index"; import Container from "./Container"; import FilterSideBar from "./Filters"; import getReportData from "../api/accountsAging"; +import { getQueryParams } from "../api/applyFilter"; import EntryContext from "../context/EntryContext"; import OutstandingOverdueInvoiceContext from "../context/outstandingOverdueInvoiceContext"; import RevenueByClientReportContext from "../context/RevenueByClientContext"; @@ -75,6 +77,18 @@ const AccountsAgingReport = () => { }, }; + const handleDownload = async type => { + const queryParams = getQueryParams(selectedFilter).substring(1); + const response = await accountsAgingApi.download(type, `?${queryParams}`); + const url = window.URL.createObjectURL(new Blob([response.data])); + const link = document.createElement("a"); + // const filename = `${selectedFilter.dateRange.label}.${type}`; + const filename = `report.${type}`; + link.href = url; + link.setAttribute("download", filename); + link.click(); + }; + if (loading) { return ; } @@ -82,12 +96,12 @@ const AccountsAgingReport = () => { return (
{}} // eslint-disable-line @typescript-eslint/no-empty-function setIsFilterVisible={setIsFilterVisible} - showExportButon={false} showNavFilters={showNavFilters} type="Accounts Aging Report" /> diff --git a/app/services/reports/accounts_aging/download_service.rb b/app/services/reports/accounts_aging/download_service.rb new file mode 100644 index 0000000000..b1805745e5 --- /dev/null +++ b/app/services/reports/accounts_aging/download_service.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Reports::AccountsAging + class DownloadService < Reports::DownloadService + attr_reader :current_company, :reports + + def initialize(params, current_company) + super + @reports = [] + end + + private + + def fetch_complete_report + @reports = FetchOverdueAmount.new(current_company).process + end + + def generate_pdf + Reports::GeneratePdf.new(:accounts_aging, reports, current_company).process + end + + def generate_csv + csv_data = [] + headers = ["Client Name", "0-30 Days", "31-60 Days", "61-90 Days", "90+ Days", "Total"] + reports[:clients].each do |client| + csv_data << [ + client[:name], + format_amount(client[:amount_overdue][:zero_to_thirty_days]), + format_amount(client[:amount_overdue][:thirty_one_to_sixty_days]), + format_amount(client[:amount_overdue][:sixty_one_to_ninety_days]), + format_amount(client[:amount_overdue][:ninety_plus_days]), + format_amount(client[:amount_overdue][:total]) + ] + end + + csv_data << [ + "Total Amounts", + reports[:total_amount_overdue_by_date_range][:zero_to_thirty_days], + reports[:total_amount_overdue_by_date_range][:thirty_one_to_sixty_days], + reports[:total_amount_overdue_by_date_range][:sixty_one_to_ninety_days], + reports[:total_amount_overdue_by_date_range][:ninety_plus_days], + reports[:total_amount_overdue_by_date_range][:total] + ] + + Reports::GenerateCsv.new(csv_data, headers).process + end + + def format_amount(amount) + FormatAmountService.new(reports[:base_currency], amount).process + end + end +end diff --git a/app/services/reports/download_service.rb b/app/services/reports/download_service.rb new file mode 100644 index 0000000000..4d3e484492 --- /dev/null +++ b/app/services/reports/download_service.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +class Reports::DownloadService + attr_reader :params, :current_company + + def initialize(params, current_company) + @params = params + @current_company = current_company + end + + def process + fetch_complete_report + format_report + end + + private + + def fetch_complete_report + raise NotImplementedError, "Subclasses must implement a 'fetch_complete_report' method." + end + + def format_report + if params[:format] == "pdf" + generate_pdf + else + generate_csv + end + end + + def generate_pdf + raise NotImplementedError, "Implement generate_pdf in the inheriting class" + end + + def generate_csv + raise NotImplementedError, "Implement generate_csv in the inheriting class" + end +end diff --git a/app/services/reports/generate_csv.rb b/app/services/reports/generate_csv.rb new file mode 100644 index 0000000000..f7471ff8e0 --- /dev/null +++ b/app/services/reports/generate_csv.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require "csv" + +class Reports::GenerateCsv + attr_reader :data, :headers + + def initialize(data, headers) + @data = data + @headers = headers + end + + def process + CSV.generate do |csv| + csv << headers + data.each do |row| + csv << row + end + end + end +end diff --git a/app/services/reports/generate_pdf.rb b/app/services/reports/generate_pdf.rb new file mode 100644 index 0000000000..b1a9e1c4b3 --- /dev/null +++ b/app/services/reports/generate_pdf.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +class Reports::GeneratePdf + attr_reader :report_data, :current_company, :report_type + + def initialize(report_type, report_data, current_company) + @report_type = report_type + @report_data = report_data + @current_company = current_company + end + + def process + case report_type + when :time_entries, :accounts_aging + generate_pdf(report_type) + else + raise ArgumentError, "Unsupported report type: #{report_type}" + end + end + + private + + def generate_pdf(report_type) + Pdf::HtmlGenerator.new( + report_type, + locals: { report_data:, current_company: } + ).make + end +end diff --git a/app/services/reports/time_entries/download_service.rb b/app/services/reports/time_entries/download_service.rb index 7ce95635e2..bb70a22331 100644 --- a/app/services/reports/time_entries/download_service.rb +++ b/app/services/reports/time_entries/download_service.rb @@ -1,20 +1,13 @@ # frozen_string_literal: true -class Reports::TimeEntries::DownloadService - attr_reader :params, :current_company, :reports +class Reports::TimeEntries::DownloadService < Reports::DownloadService + attr_reader :reports def initialize(params, current_company) - @params = params - @current_company = current_company - + super @reports = [] end - def process - fetch_complete_report - format_report - end - private def fetch_complete_report @@ -31,12 +24,28 @@ def fetch_complete_report end end - def format_report - if params[:format] == "pdf" - Reports::TimeEntries::GeneratePdf.new(reports, current_company).process - else - flatten_reports = reports.map { |e| e[:entries] }.flatten - Reports::TimeEntries::GenerateCsv.new(flatten_reports, current_company).process - end + def generate_pdf + Reports::GeneratePdf.new(:time_entries, reports, current_company).process + end + + def generate_csv + data = [] + headers = ["Project", "Client", "Note", "Team Member", "Date", "Hours Logged"] + flatten_reports = reports.map { |e| e[:entries] }.flatten + flatten_reports.each do |entry| + data << [ + "#{entry.project_name}", + "#{entry.client_name}", + "#{entry.note}", + "#{entry.user_name}", + "#{format_date(entry.work_date)}", + "#{DurationFormatter.new(entry.duration).process}" + ] + end + Reports::GenerateCsv.new(data, headers).process + end + + def format_date(date) + CompanyDateFormattingService.new(date, company: current_company, es_date_presence: true).process end end diff --git a/app/services/reports/time_entries/generate_csv.rb b/app/services/reports/time_entries/generate_csv.rb deleted file mode 100644 index 97cfc43a9b..0000000000 --- a/app/services/reports/time_entries/generate_csv.rb +++ /dev/null @@ -1,36 +0,0 @@ -# frozen_string_literal: true - -require "csv" - -module Reports::TimeEntries - class GenerateCsv - attr_reader :entries, :current_company - - def initialize(entries, current_company) - @entries = entries - @current_company = current_company - end - - def process - CSV.generate(headers: true) do |csv| - csv << ["Project", "Client", "Note", "Team Member", "Date", "Hours Logged"] - entries.each do |entry| - csv << [ - "#{entry.project_name}", - "#{entry.client_name}", - "#{entry.note}", - "#{entry.user_name}", - "#{format_date(entry.work_date)}", - "#{DurationFormatter.new(entry.duration).process}" - ] - end - end - end - - private - - def format_date(date) - CompanyDateFormattingService.new(date, company: current_company, es_date_presence: true).process - end - end -end diff --git a/app/services/reports/time_entries/generate_pdf.rb b/app/services/reports/time_entries/generate_pdf.rb deleted file mode 100644 index 62f88b725e..0000000000 --- a/app/services/reports/time_entries/generate_pdf.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -module Reports::TimeEntries - class GeneratePdf - attr_reader :report_entries, :current_company - - def initialize(report_entries, current_company) - @report_entries = report_entries - @current_company = current_company - end - - def process - Pdf::HtmlGenerator.new( - :reports, - locals: { report_entries:, current_company: } - ).make - end - end -end diff --git a/app/views/pdfs/accounts_aging.html.erb b/app/views/pdfs/accounts_aging.html.erb new file mode 100644 index 0000000000..b7891565d4 --- /dev/null +++ b/app/views/pdfs/accounts_aging.html.erb @@ -0,0 +1,94 @@ +
+

Accounts Aging Report

+
+ +
+ + + + + + + + + + + <% report_data[:clients].each do |client| %> + + + + + + + + + <% end %> + + + + + + + + + +
+

Client

+
+ 0-30 days + +

31-60 days

+
+

61-90 days

+
+

90+ days

+
+

Total

+
+

+ <%= client[:name] %> +

+
+

+ <%= FormatAmountService.new(report_data[:base_currency],client[:amount_overdue][:zero_to_thirty_days]).process %> +

+
+ <%= FormatAmountService.new(report_data[:base_currency],client[:amount_overdue][:thirty_one_to_sixty_days]).process %> + +

+ <%= FormatAmountService.new(report_data[:base_currency],client[:amount_overdue][:sixty_one_to_ninety_days]).process %> +

+
+

+ <%= FormatAmountService.new(report_data[:base_currency],client[:amount_overdue][:ninety_plus_days]).process %> +

+
+

+ <%= FormatAmountService.new(report_data[:base_currency],client[:amount_overdue][:total]).process %> +

+
+

+ Total +

+
+

+ <%= FormatAmountService.new(report_data[:base_currency],report_data[:total_amount_overdue_by_date_range][:zero_to_thirty_days]).process %> +

+
+

+ <%= FormatAmountService.new(report_data[:base_currency],report_data[:total_amount_overdue_by_date_range][:thirty_one_to_sixty_days]).process %> +

+
+

+ <%= FormatAmountService.new(report_data[:base_currency],report_data[:total_amount_overdue_by_date_range][:sixty_one_to_ninety_days]).process %> +

+
+

+ <%= FormatAmountService.new(report_data[:base_currency],report_data[:total_amount_overdue_by_date_range][:ninety_plus_days]).process %> +

+
+

+ <%= FormatAmountService.new(report_data[:base_currency],report_data[:total_amount_overdue_by_date_range][:total]).process %> +

+
+
diff --git a/app/views/pdfs/reports.html.erb b/app/views/pdfs/time_entries.html.erb similarity index 98% rename from app/views/pdfs/reports.html.erb rename to app/views/pdfs/time_entries.html.erb index a5d0528928..917bc3cdbc 100644 --- a/app/views/pdfs/reports.html.erb +++ b/app/views/pdfs/time_entries.html.erb @@ -21,7 +21,7 @@ - <% report_entries.each do |report| %> + <% report_data.each do |report| %> <% report[:entries].each do |entry| %> diff --git a/config/routes/internal_api.rb b/config/routes/internal_api.rb index 3544e69123..169dfcf185 100644 --- a/config/routes/internal_api.rb +++ b/config/routes/internal_api.rb @@ -50,7 +50,11 @@ end end resources :outstanding_overdue_invoices, only: [:index] - resources :accounts_aging, only: [:index] + resources :accounts_aging, only: [:index] do + collection do + get :download + end + end end resources :workspaces, only: [:index, :update] diff --git a/spec/services/reports/accounts_aging/download_service_spec.rb b/spec/services/reports/accounts_aging/download_service_spec.rb new file mode 100644 index 0000000000..09878446c3 --- /dev/null +++ b/spec/services/reports/accounts_aging/download_service_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Reports::AccountsAging::DownloadService do + let(:current_company) { create(:company) } + + describe "#process" do + let(:params) { { some_param: "value" } } + let(:reports_data) { { clients: [], total_amount_overdue_by_date_range: {} } } + + subject { described_class.new(params, current_company) } + + before do + allow(Reports::AccountsAging::FetchOverdueAmount).to receive(:new).and_return( + double( + "FetchOverdueAmount", + process: reports_data)) + allow(Reports::GeneratePdf).to receive(:new).and_return(double("Reports::GeneratePdf", process: nil)) + allow(Reports::GenerateCsv).to receive(:new).and_return(double("Reports::GenerateCsv", process: nil)) + end + + it "fetches complete report, generates PDF and CSV" do + allow(subject).to receive(:fetch_complete_report) + allow(subject).to receive(:generate_pdf) + allow(subject).to receive(:generate_csv) + + subject.process + end + + it "fetches complete report and generates CSV" do + allow(subject).to receive(:fetch_complete_report) + allow(subject).to receive(:generate_csv) + + subject.process + end + end +end diff --git a/spec/services/reports/generate_csv_spec.rb b/spec/services/reports/generate_csv_spec.rb new file mode 100644 index 0000000000..7feb8181e6 --- /dev/null +++ b/spec/services/reports/generate_csv_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Reports::GenerateCsv do + describe "#process" do + let(:headers) { ["Name", "Age", "Email"] } + let(:data) do + [ + ["John Doe", "30", "john@example.com"], + ["Jane Smith", "25", "jane@example.com"] + ] + end + + subject { described_class.new(data, headers) } + + it "generates CSV data with headers and data" do + csv_data = subject.process + parsed_csv = CSV.parse(csv_data) + + expect(parsed_csv.first).to eq(headers) + + expect(parsed_csv[1]).to eq(data.first) + expect(parsed_csv[2]).to eq(data.second) + end + end +end diff --git a/spec/services/reports/generate_pdf_spec.rb b/spec/services/reports/generate_pdf_spec.rb new file mode 100644 index 0000000000..be927b3f81 --- /dev/null +++ b/spec/services/reports/generate_pdf_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Reports::GeneratePdf do + let(:report_data) { double("report_data") } + let(:current_company) { double("current_company") } + + describe "#process" do + context "when report type is time_entries" do + subject { described_class.new(:time_entries, report_data, current_company) } + + it "generates PDF for time entries" do + allow(Pdf::HtmlGenerator).to receive(:new).with( + :time_entries, + locals: { report_data:, current_company: }).and_return(double("Pdf::HtmlGenerator", make: nil)) + subject.process + end + end + + context "when report type is accounts_aging" do + subject { described_class.new(:accounts_aging, report_data, current_company) } + + it "generates PDF for accounts aging" do + allow(Pdf::HtmlGenerator).to receive(:new).with( + :accounts_aging, + locals: { report_data:, current_company: }).and_return(double("Pdf::HtmlGenerator", make: nil)) + subject.process + end + end + + context "when report type is unsupported" do + it "raises ArgumentError" do + expect { + described_class.new(:unsupported_report_type, report_data, current_company).process + }.to raise_error(ArgumentError, "Unsupported report type: unsupported_report_type") + end + end + end +end diff --git a/spec/services/reports/time_entries/download_service_spec.rb b/spec/services/reports/time_entries/download_service_spec.rb index ed1e9a16f7..6a2f79a47d 100644 --- a/spec/services/reports/time_entries/download_service_spec.rb +++ b/spec/services/reports/time_entries/download_service_spec.rb @@ -6,6 +6,10 @@ let(:company) { create(:company) } let(:client) { create(:client, :with_logo, company:) } let(:project) { create(:project, client:) } + let(:csv_headers) do + "Project,Client,Note,Team Member,Date,Hours Logged" + end + let(:report_entries) { [double("TimeEntry")] } before do create_list(:user, 12) @@ -31,5 +35,20 @@ all_users_with_name = User.all.order(:first_name).map { |u| u.full_name } expect(data.pluck(:label)).to eq(all_users_with_name) end + + it "generates CSV report" do + data = subject.process + expect(data).to include(csv_headers) + end + + it "generates a PDF report using Pdf::HtmlGenerator" do + subject { described_class.new(report_entries, current_company) } + + html_generator = instance_double("Pdf::HtmlGenerator") + allow(Pdf::HtmlGenerator).to receive(:new).and_return(html_generator) + + allow(html_generator).to receive(:make) + subject.process + end end end diff --git a/spec/services/reports/time_entries/generate_csv_spec.rb b/spec/services/reports/time_entries/generate_csv_spec.rb deleted file mode 100644 index abcaf43bf2..0000000000 --- a/spec/services/reports/time_entries/generate_csv_spec.rb +++ /dev/null @@ -1,33 +0,0 @@ -# frozen_string_literal: true - -require "rails_helper" - -RSpec.describe Reports::TimeEntries::GenerateCsv do - let(:company) { create(:company) } - let!(:entry) { create(:timesheet_entry) } - - describe "#process" do - before do - TimesheetEntry.reindex - end - - subject { described_class.new(TimesheetEntry.search(load: false), company).process } - - let(:csv_headers) do - "Project,Client,Note,Team Member,Date,Hours Logged" - end - let(:csv_data) do - "#{entry.project_name}," \ - "#{entry.client_name}," \ - "#{entry.note}," \ - "#{entry.user_full_name}," \ - "#{entry.formatted_work_date}," \ - "#{entry.formatted_duration}" - end - - it "returns CSV string" do - expect(subject).to include(csv_headers) - expect(subject).to include(csv_data) - end - end -end diff --git a/spec/services/reports/time_entries/generate_pdf_spec.rb b/spec/services/reports/time_entries/generate_pdf_spec.rb deleted file mode 100644 index 09e1486b89..0000000000 --- a/spec/services/reports/time_entries/generate_pdf_spec.rb +++ /dev/null @@ -1,20 +0,0 @@ -# frozen_string_literal: true - -require "rails_helper" - -RSpec.describe Reports::TimeEntries::GeneratePdf do - let(:report_entries) { [double("TimeEntry")] } - let(:current_company) { double("Company") } - - subject { described_class.new(report_entries, current_company) } - - describe "#process" do - it "generates a PDF report using Pdf::HtmlGenerator" do - html_generator = instance_double("Pdf::HtmlGenerator") - allow(Pdf::HtmlGenerator).to receive(:new).and_return(html_generator) - - allow(html_generator).to receive(:make) - subject.process - end - end -end