Skip to content
This repository was archived by the owner on Jul 27, 2025. It is now read-only.
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@
# Ignore .devcontainer files
compose-dev.yaml

# Ignore asdf ruby version file
.tool-versions

# Ignore GCP keyfile
gcp-storage-keyfile.json

Expand Down
15 changes: 15 additions & 0 deletions app/controllers/imports_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,21 @@ def destroy
def load
end

def upload_csv
file = import_params[:raw_csv_str]
@import.raw_csv_str = file.read
CSV.parse(@import.raw_csv_str)
if @import.save
redirect_to configure_import_path(@import), notice: t(".import_loaded")
else
flash.now[:error] = @import.errors.full_messages.to_sentence
render :load, status: :unprocessable_entity
end
rescue CSV::MalformedCSVError, ArgumentError => error
flash.now[:error] = error.message
render :load, status: :unprocessable_entity
end

def load_csv
if @import.update(import_params)
redirect_to configure_import_path(@import), notice: t(".import_loaded")
Expand Down
95 changes: 95 additions & 0 deletions app/javascript/controllers/csv_upload_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
static targets = ["input", "preview", "submit", "progressBar", "progressContainer", "filename", "filesize"]

connect() {
this.submitTarget.disabled = true
this.progressContainerTarget.classList.add("hidden")
}

addFile(event) {
const file = event.target.files[0]
this._fileAdded(file)
}

dragover(event) {
event.preventDefault()
event.stopPropagation()
event.currentTarget.classList.add("bg-gray-100")
}

dragleave(event) {
event.preventDefault()
event.stopPropagation()
event.currentTarget.classList.remove("bg-gray-100")
}

drop(event) {
event.preventDefault()
event.stopPropagation()
event.currentTarget.classList.remove("bg-gray-100")

const file = event.dataTransfer.files[0]
if (file && this._isCSVFile(file)) {
this._setFileInput(file);
this._fileAdded(file)
} else {
this.previewTarget.classList.add("text-red-500")
this.previewTarget.textContent = "Only CSV files are allowed."
}
}

// Private

_fetchFileSize(size) {
let fileSize = '';
if (size < 1024 * 1024) {
fileSize = (size / 1024).toFixed(2) + ' KB'; // Convert bytes to KB
} else {
fileSize = (size / (1024 * 1024)).toFixed(2) + ' MB'; // Convert bytes to MB
}
return fileSize;
}

_fileAdded(file) {
const fileSizeLimit = 5 * 1024 * 1024 // 5MB

if (file) {
if (file.size > fileSizeLimit) {
this.previewTarget.classList.add("text-red-500")
this.previewTarget.textContent = "File size exceeds the limit of 5MB"
return
}

this.submitTarget.classList.remove([
"bg-alpha-black-25",
"text-gray",
"cursor-not-allowed",
]);
this.submitTarget.classList.add(
"bg-gray-900",
"text-white",
"cursor-pointer",
);
this.submitTarget.disabled = false;
this.previewTarget.innerHTML = document.querySelector("#template-preview").innerHTML;
this.previewTarget.classList.remove("text-red-500")
this.previewTarget.classList.add("text-gray-900")
this.filenameTarget.textContent = file.name;
this.filesizeTarget.textContent = this._fetchFileSize(file.size);
}
}

_isCSVFile(file) {
const acceptedTypes = ["text/csv", "application/csv", ".csv"]
const extension = file.name.split('.').pop().toLowerCase()
return acceptedTypes.includes(file.type) || extension === "csv"
}

_setFileInput(file) {
const dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
this.inputTarget.files = dataTransfer.files;
}
}
27 changes: 27 additions & 0 deletions app/views/imports/_csv_paste.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<%= form_with model: @import, url: load_import_path(@import) do |form| %>
<div>
<%= form.text_area :raw_csv_str,
rows: 10,
required: true,
placeholder: "Paste your CSV file contents here",
class: "rounded-md w-full border text-sm border-alpha-black-100 bg-white placeholder:text-gray-400 p-4" %>
</div>

<%= form.submit t(".next"), class: "px-4 py-2 mb-4 block w-full rounded-lg bg-gray-900 text-white text-sm font-medium", data: { turbo_confirm: (@import.raw_csv_str? ? { title: t(".confirm_title"), body: t(".confirm_body"), accept: t(".confirm_accept") } : nil) } %>
<% end %>

<div class="bg-alpha-black-25 rounded-xl p-1 mt-5">
<div class="text-gray-500 p-2 mb-2">
<div class="flex gap-2 mb-2">
<%= lucide_icon("info", class: "w-5 h-5 shrink-0") %>
<p class="text-sm"><%= t(".instructions") %></p>
</div>

<ul class="list-disc text-sm pl-10">
<li><%= t(".requirement1") %></li>
<li><%= t(".requirement2") %></li>
<li><%= t(".requirement3") %></li>
</ul>
</div>
<%= render partial: "imports/sample_table" %>
</div>
44 changes: 44 additions & 0 deletions app/views/imports/_csv_upload.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<%= form_with model: @import, url: upload_import_path(@import), class: "dropzone", data: { controller: "csv-upload" }, method: :patch, multipart: true do |form| %>
<div class="flex items-center justify-center w-full">
<label for="import_raw_csv_str" class="flex flex-col items-center justify-center w-full h-64 border-2 border-gray-300 border-dashed rounded-lg cursor-pointer bg-gray-50" data-action="dragover->csv-upload#dragover dragleave->csv-upload#dragleave drop->csv-upload#drop">
<div class="flex flex-col items-center justify-center pt-5 pb-6">
<%= lucide_icon "plus", class: "w-5 h-5 text-gray-500" %>
<%= form.file_field :raw_csv_str, class: "hidden", direct_upload: false, accept: "text/csv,.csv,application/csv", data: { csv_upload_target: "input", action: "change->csv-upload#addFile" } %>
<p class="mb-2 text-sm text-gray-500 mt-3">Drag and drop your csv file here or <span class="text-black">click to browse</span></p>
<p class="text-xs text-gray-500">CSV (Max. 5MB)</p>
<div class="csv-preview" data-csv-upload-target="preview"></div>
<div class="w-full mt-4 hidden" data-csv-upload-target="progressContainer">
<div class="bg-gray-200 rounded-full h-2.5 dark:bg-gray-700">
<div class="bg-green-600 h-3 rounded-full text-xs font-medium text-blue-100 text-center p-0.5 leading-none" data-csv-upload-target="progressBar">0%</div>
</div>
</div>
</div>
</label>
</div>
<%= form.submit t(".next"), class: "px-4 py-2 mb-4 block w-full rounded-lg bg-alpha-black-25 text-gray text-sm font-medium", data: { csv_upload_target: "submit", turbo_confirm: (@import.raw_csv_str? ? { title: t(".confirm_title"), body: t(".confirm_body"), accept: t(".confirm_accept") } : nil) } %>
<% end %>

<div id="template-preview" class="hidden">
<div class="flex flex-col items-center justify-center">
<%= lucide_icon "file-text", class: "w-10 h-10 pt-2 text-black" %>
<div class="flex flex-row items-center justify-center gap-0.5">
<div><span data-csv-upload-target="filename"></span></div>
<div><span data-csv-upload-target="filesize" class="font-semibold"></span></div>
</div>
</div>
</div>

<div class="bg-alpha-black-25 rounded-xl p-1 mt-5">
<div class="text-gray-500 p-2 mb-2">
<div class="flex gap-2 mb-2">
<%= lucide_icon("info", class: "w-5 h-5 shrink-0") %>
<p class="text-sm">
<%= t(".instructions") %>
<span class="text-black underline">
<%= link_to 'download this template', '/transactions.csv', download: '' %>
</span>
</p>
</div>
</div>
<%= render partial: "imports/sample_table" %>
</div>
37 changes: 11 additions & 26 deletions app/views/imports/load.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -8,33 +8,18 @@
<p class="text-gray-500 text-sm"><%= t(".description") %></p>
</div>

<%= form_with model: @import, url: load_import_path(@import) do |form| %>
<div>
<%= form.text_area :raw_csv_str,
rows: 10,
required: true,
placeholder: "Paste your CSV file contents here",
class: "rounded-md w-full border text-sm border-alpha-black-100 bg-white placeholder:text-gray-400 p-4" %>
</div>

<%= form.submit t(".next"), class: "px-4 py-2 block w-full rounded-lg bg-gray-900 text-white text-sm font-medium cursor-pointer hover:bg-gray-700", data: { turbo_confirm: (@import.raw_csv_str? ? { title: t(".confirm_title"), body: t(".confirm_body"), accept: t(".confirm_accept") } : nil) } %>
<% end %>

<div class="bg-alpha-black-25 rounded-xl p-1">
<div class="text-gray-500 p-2 mb-2">
<div class="flex gap-2 mb-2">
<%= lucide_icon("info", class: "w-5 h-5 shrink-0") %>
<p class="text-sm"><%= t(".instructions") %></p>
<div data-controller="tabs" data-tabs-active-class="bg-white" data-tabs-default-tab-value="csv-upload-tab">
<div class="flex justify-center mb-4">
<div class="bg-gray-50 rounded-lg inline-flex p-1 space-x-2 text-sm text-gray-900 font-medium">
<button data-id="csv-upload-tab" class="p-2 rounded-lg" data-tabs-target="btn" data-action="click->tabs#select">Upload CSV</button>
<button data-id="csv-paste-tab" class="p-2 rounded-lg" data-tabs-target="btn" data-action="click->tabs#select">Copy & Paste</button>
</div>

<ul class="list-disc text-sm pl-10">
<li><%= t(".requirement1") %></li>
<li><%= t(".requirement2") %></li>
<li><%= t(".requirement3") %></li>
</ul>
</div>

<%= render partial: "imports/sample_table" %>

<div data-tabs-target="tab" id="csv-upload-tab">
<%= render partial: "imports/csv_upload", locals: { import: @import } %>
</div>
<div data-tabs-target="tab" id="csv-paste-tab" class="hidden">
<%= render partial: "imports/csv_paste", locals: { import: @import } %>
</div>
</div>
</div>
31 changes: 25 additions & 6 deletions config/locales/views/imports/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,29 @@ en:
account: Account
next: Next
select_account: Select account
csv_paste:
next: Next
confirm_title: Are you sure?
confirm_body:
This will reset your import. Any changes you have made to the
CSV will be erased.
confirm_accept: Yep, start over!
instructions:
Your CSV should have the following columns and formats for the
best import experience.
requirement1: Dates must be in ISO 8601 format (YYYY-MM-DD)
requirement2:
Negative transaction is an "outflow" (expense), positive is an
"inflow" (income)
requirement3: Can have 0 or more tags separated by |
csv_upload:
next: Next
confirm_title: Are you sure?
confirm_body:
This will reset your import. Any changes you have made to the
CSV will be erased.
confirm_accept: Yep, start over!
instructions: The csv file must be in the format below. You can also reuse and
import:
complete: Complete
completed_on: Completed on %{datetime}
Expand All @@ -62,17 +85,13 @@ en:
confirm_title: Are you sure?
description: Create a spreadsheet or upload an exported CSV from your financial
institution.
instructions: Your CSV should have the following columns and formats for the
best import experience.
load_title: Load import
next: Next
requirement1: Dates must be in ISO 8601 format (YYYY-MM-DD)
requirement2: Negative transaction is an "outflow" (expense), positive is an
"inflow" (income)
requirement3: Can have 0 or more tags separated by |
subtitle: Import your transactions
load_csv:
import_loaded: Import CSV loaded
upload_csv:
import_loaded: CSV File loaded
new:
description_text: Importing transactions can only be done for one account at
a time. You will need to go through this process again for other accounts.
Expand Down
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
member do
get "load"
patch "load" => "imports#load_csv"
patch "upload" => "imports#upload_csv"

get "configure"
patch "configure" => "imports#update_mappings"
Expand Down
4 changes: 4 additions & 0 deletions public/transactions.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
date,name,category,tags,amount
2024-01-01,Amazon,Shopping,Tag1|Tag2,-24.99
2024-03-01,Spotify,,,-16.32
2023-01-06,Acme,Income,Tag3,151.22