CSV Transaction Imports (#708)

Introduces a basic CSV import module for bulk-importing account transactions.

Changes include:

- User can load a CSV
- User can configure the column mappings for a CSV
- Imported CSV shows invalid cells
- User can clean up their data directly in the UI
- User can see a preview of the import rows and confirm import
- Layout refactor + Import nav stepper
- System test stability improvements
This commit is contained in:
Zach Gollwitzer
2024-05-17 09:09:32 -04:00
committed by GitHub
parent 3d9ff3ad2a
commit 45ae4a9737
71 changed files with 1657 additions and 117 deletions

View File

@@ -0,0 +1,9 @@
<div class="flex justify-center items-center py-20">
<div class="text-center flex flex-col items-center max-w-[300px]">
<p class="text-gray-900 mb-1 font-medium text-sm"><%= t(".message") %></p>
<%= link_to new_import_path(enable_type_selector: true), class: "w-fit flex text-white text-sm font-medium items-center gap-1 bg-gray-900 rounded-lg p-2 pr-3", data: { turbo_frame: "modal" } do %>
<%= lucide_icon("plus", class: "w-5 h-5") %>
<span><%= t(".new") %></span>
<% end %>
</div>
</div>

View File

@@ -0,0 +1,7 @@
<%= form_with model: @import do |form| %>
<div class="mb-4">
<%= form.collection_select :account_id, Current.family.accounts.alphabetically, :id, :name, { prompt: t(".select_account"), label: t(".account"), required: true } %>
</div>
<%= form.submit t(".next"), class: "px-4 py-2 block w-full rounded-lg bg-gray-900 text-white text-sm font-medium" %>
<% end %>

View File

@@ -0,0 +1,61 @@
<div id="<%= dom_id import %>" class="flex items-center justify-between mx-4 py-5 border-b last:border-b-0 border-alpha-black-50">
<div>
<div class="flex items-center gap-1 mb-1">
<p class="text-sm text-gray-900">
<%= t(".label", account: import.account.name) %>
</p>
<% if import.pending? %>
<span class="px-1 py text-xs rounded-full bg-gray-500/5 text-gray-500 border border-alpha-black-50">
<%= t(".in_progress") %>
</span>
<% elsif import.importing? %>
<span class="px-1 py text-xs animate-pulse rounded-full bg-orange-500/5 text-orange-500 border border-alpha-black-50">
<%= t(".uploading") %>
</span>
<% elsif import.failed? %>
<span class="px-1 py text-xs rounded-full bg-red-500/5 text-red-500 border border-alpha-black-50">
<%= t(".failed") %>
</span>
<% elsif import.complete? %>
<span class="px-1 py text-xs rounded-full bg-green-500/5 text-green-500 border border-alpha-black-50">
<%= t(".complete") %>
</span>
<% end %>
</div>
<% if import.complete? %>
<p class="text-xs text-gray-500"><%= t(".completed_on", datetime: import.updated_at.strftime("%Y-%m-%d")) %></p>
<% else %>
<p class="text-xs text-gray-500"><%= t(".started_on", datetime: import.created_at.strftime("%Y-%m-%d")) %></p>
<% end %>
</div>
<% if import.complete? %>
<div class="w-7 h-7 bg-green-500/5 flex items-center justify-center rounded-full">
<%= lucide_icon("check", class: "text-green-500 w-4 h-4") %>
</div>
<% else %>
<%= contextual_menu do %>
<div class="w-48 p-1 text-sm leading-6 text-gray-900 bg-white shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
<%= link_to edit_import_path(import),
class: "block w-full py-2 px-3 space-x-2 text-gray-900 hover:bg-gray-50 flex items-center rounded-lg" do %>
<%= lucide_icon "pencil-line", class: "w-5 h-5 text-gray-500" %>
<span><%= t(".edit") %></span>
<% end %>
<%= button_to import_path(import),
method: :delete,
class: "block w-full py-2 px-3 space-x-2 text-red-600 hover:bg-red-50 flex items-center rounded-lg",
data: { turbo_confirm: true } do %>
<%= lucide_icon "trash-2", class: "w-5 h-5" %>
<span><%= t(".delete") %></span>
<% end %>
</div>
<% end %>
<% end %>
</div>

View File

@@ -0,0 +1,18 @@
<% is_current = request.path == step[:path] %>
<% text_class = if is_current
"text-gray-900"
else
step[:complete] ? "text-green-600" : "text-gray-500"
end %>
<% step_class = if is_current
"bg-gray-900 text-white"
else
step[:complete] ? "bg-green-600/10 border-alpha-black-25" : "bg-gray-50"
end %>
<div class="flex items-center gap-2 text-sm font-medium <%= text_class %>">
<span class="<%= step_class %> w-7 h-7 rounded-full shrink-0 inline-flex items-center justify-center border border-transparent">
<%= step[:complete] && !is_current ? lucide_icon("check", class: "w-4 h-4") : step_idx + 1 %>
</span>
<span><%= step[:name] %></span>
</div>

View File

@@ -0,0 +1,22 @@
<!--TODO: Once we have more styled tables for reference, refactor and DRY this up -->
<div class="grid grid-cols-4 border border-alpha-black-200 rounded-md shadow-xs text-sm bg-white">
<div class="bg-gray-25 px-3 py-2.5 border-b border-b-alpha-black-200 font-medium rounded-tl-md">Date</div>
<div class="bg-gray-25 px-3 py-2.5 border-b border-b-alpha-black-200 font-medium">Name</div>
<div class="bg-gray-25 px-3 py-2.5 border-b border-b-alpha-black-200 font-medium">Category</div>
<div class="bg-gray-25 px-3 py-2.5 border-b border-b-alpha-black-200 font-medium rounded-tr-md">Amount</div>
<div class="px-3 py-2.5 border-b border-b-alpha-black-200">2024-01-01</div>
<div class="px-3 py-2.5 border-b border-b-alpha-black-200">Amazon</div>
<div class="px-3 py-2.5 border-b border-b-alpha-black-200">Shopping</div>
<div class="px-3 py-2.5 border-b border-b-alpha-black-200">-24.99</div>
<div class="px-3 py-2.5 border-b border-b-alpha-black-200">2024-03-01</div>
<div class="px-3 py-2.5 border-b border-b-alpha-black-200">Spotify</div>
<div class="px-3 py-2.5 border-b border-b-alpha-black-200"></div>
<div class="px-3 py-2.5 border-b border-b-alpha-black-200">-16.32</div>
<div class="px-3 py-2.5 border-b border-b-alpha-black-200 rounded-bl-md">2023-01-06</div>
<div class="px-3 py-2.5 border-b border-b-alpha-black-200">Acme</div>
<div class="px-3 py-2.5 border-b border-b-alpha-black-200">Income</div>
<div class="px-3 py-2.5 border-b border-b-alpha-black-200 rounded-br-md">151.22</div>
</div>

View File

@@ -0,0 +1,90 @@
<div class="p-4 space-y-4 max-w-[420px]">
<div class="space-y-2">
<div class="flex justify-between items-center">
<h2 class="font-medium text-gray-900"><%= t(".import_transactions") %></h2>
<button data-action="modal#close">
<%= lucide_icon("x", class: "w-5 h-5 text-gray-900") %>
</button>
</div>
<p class="text-gray-500 text-sm"><%= t(".description") %></p>
</div>
<div class="rounded-xl bg-gray-25 p-1">
<h3 class="uppercase text-gray-500 text-xs font-medium px-3 py-1.5"><%= t(".sources") %></h3>
<ul class="bg-white border border-alpha-black-25 rounded-lg shadow-xs">
<li>
<% if Current.family.imports.pending.present? %>
<%= link_to edit_import_path(Current.family.imports.pending.ordered.first), class: "flex items-center gap-3 p-4 group cursor-pointer", data: { turbo: false } do %>
<div class="bg-orange-500/5 rounded-md w-8 h-8 flex items-center justify-center">
<%= lucide_icon("loader", class: "w-5 h-5 text-orange-500") %>
</div>
<span class="text-sm text-gray-900 group-hover:text-gray-700">
<%= t(".resume_latest_import") %>
</span>
<%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-500 ml-auto") %>
<% end %>
<div class="pl-14 pr-3">
<div class="h-px bg-alpha-black-50"></div>
</div>
</li>
<% end %>
<li>
<%= link_to new_import_path, class: "flex items-center gap-3 p-4 group cursor-pointer", data: { turbo: false } do %>
<div class="bg-indigo-500/5 rounded-md w-8 h-8 flex items-center justify-center">
<%= lucide_icon("file-spreadsheet", class: "w-5 h-5 text-indigo-500") %>
</div>
<span class="text-sm text-gray-900 group-hover:text-gray-700">
<%= t(".import_from_csv") %>
</span>
<%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-500 ml-auto") %>
<% end %>
<div class="pl-14 pr-3">
<div class="h-px bg-alpha-black-50"></div>
</div>
</li>
<li>
<div class="flex items-center gap-3 p-4 group cursor-not-allowed">
<%= image_tag("mint-logo.jpeg", alt: "Mint logo", class: "w-8 h-8 rounded-md") %>
<span class="text-sm text-gray-900 group-hover:text-gray-700">
<%= t(".import_from_mint") %>
</span>
<span class="bg-indigo-500/5 rounded-full px-1.5 py-0.5 border border-alpha-black-25 uppercase text-xs font-medium text-indigo-500"><%= t(".soon") %></span>
<%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-300 ml-auto") %>
</div>
<div class="pl-14 pr-3">
<div class="h-px bg-alpha-black-50"></div>
</div>
</li>
<li>
<div class="flex items-center gap-3 p-4 group cursor-not-allowed">
<%= image_tag("empower-logo.jpeg", alt: "Mint logo", class: "w-8 h-8 border border-alpha-black-100 rounded-md") %>
<span class="text-sm text-gray-900 group-hover:text-gray-700">
<%= t(".import_from_empower") %>
</span>
<span class="bg-indigo-500/5 rounded-full px-1.5 py-0.5 border border-alpha-black-25 uppercase text-xs font-medium text-indigo-500"><%= t(".soon") %></span>
<%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-300 ml-auto") %>
</div>
<div class="pl-14 pr-3">
<div class="h-px bg-alpha-black-50"></div>
</div>
</li>
<li>
<div class="flex items-center gap-3 p-4 group cursor-not-allowed">
<%= image_tag("apple-logo.png", alt: "Mint logo", class: "w-8 h-8 rounded-md") %>
<span class="text-sm text-gray-900 group-hover:text-gray-700">
<%= t(".import_from_apple") %>
</span>
<span class="bg-indigo-500/5 rounded-full px-1.5 py-0.5 border border-alpha-black-25 uppercase text-xs font-medium text-indigo-500"><%= t(".soon") %></span>
<%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-300 ml-auto") %>
</div>
</li>
</ul>
</div>
</div>

View File

@@ -0,0 +1,49 @@
<%= content_for :return_to_path, return_to_path(params, imports_path) %>
<div class="mx-auto max-w-screen-md w-full py-24">
<h1 class="sr-only"><%= t(".clean_import") %></h1>
<div class="text-center space-y-2 max-w-[400px] mx-auto mb-8">
<h2 class="text-3xl text-gray-900 font-medium"><%= t(".clean_and_edit") %></h2>
<p class="text-gray-500 text-sm"><%= t(".clean_description") %></p>
</div>
<div class="bg-gray-25 rounded-xl p-1 mb-6">
<div
class="grid items-center uppercase text-xs font-medium text-gray-500 py-3"
style="grid-template-columns: repeat(<%= @import.expected_fields.size %>, 1fr);">
<% @import.expected_fields.each do |field| %>
<div class="px-5"><%= field.label %></div>
<% end %>
</div>
<div class="bg-white border border-alpha-black-200 rounded-xl shadow-xs divide-y divide-alpha-black-200">
<% @import.csv.table.each_with_index do |row, row_index| %>
<div
class="grid divide-x divide-alpha-black-200"
style="grid-template-columns: repeat(<%= @import.expected_fields.size %>, 1fr);">
<% row.fields.each_with_index do |value, col_index| %>
<%= form_with model: @import,
builder: ActionView::Helpers::FormBuilder,
url: clean_import_url(@import),
method: :patch,
data: { turbo: false, controller: "auto-submit-form", "auto-submit-form-trigger-event-value" => "blur" } do |form| %>
<%= form.fields_for :csv_update do |ff| %>
<%= ff.hidden_field :row_idx, value: row_index %>
<%= ff.hidden_field :col_idx, value: col_index %>
<%= ff.text_field :value, value: value,
id: "cell-#{row_index}-#{col_index}",
class: "#{@import.csv.cell_valid?(row_index, col_index) ? "focus:border-transparent border-transparent" : "border-red-500"} border px-4 py-3.5 text-sm w-full bg-transparent focus:ring-gray-900 focus:ring-2 focus-visible:outline-none #{table_corner_class(row_index, col_index, @import.csv.table, row.fields)}",
data: { "auto-submit-form-target" => "auto" } %>
<% end %>
<% end %>
<% end %>
</div>
<% end %>
</div>
</div>
<% if @import.csv.valid? %>
<%= link_to "Next", confirm_import_path(@import), class: "px-4 py-2 block w-60 text-center mx-auto rounded-lg bg-gray-900 text-white text-sm font-medium", data: { turbo: false } %>
<% end %>
</div>

View File

@@ -0,0 +1,24 @@
<%= content_for :return_to_path, return_to_path(params, imports_path) %>
<div class="mx-auto max-w-[400px] w-full py-24 space-y-4">
<h1 class="sr-only"><%= t(".configure_title") %></h1>
<div class="text-center space-y-2">
<h2 class="text-3xl text-gray-900 font-medium"><%= t(".configure_subtitle") %></h2>
<p class="text-gray-500 text-sm"><%= t(".configure_description") %></p>
</div>
<%= form_with model: @import, url: configure_import_path(@import) do |form| %>
<div class="mb-4 space-y-4">
<%= form.fields_for :column_mappings do |mappings| %>
<% @import.expected_fields.each do |field| %>
<%= mappings.select field.key,
options_for_select(@import.available_headers, @import.get_selected_header_for_field(field)),
label: field.label %>
<% end %>
<% end %>
</div>
<%= form.submit t(".next"), class: "px-4 py-2 block w-full rounded-lg bg-gray-900 text-white text-sm font-medium", data: { turbo_confirm: (@import.column_mappings? ? { title: t(".confirm_title"), body: t(".confirm_body"), accept: t(".confirm_accept") } : nil) } %>
<% end %>
</div>

View File

@@ -0,0 +1,16 @@
<%= content_for :return_to_path, return_to_path(params, imports_path) %>
<div class="mx-auto max-w-screen-md w-full py-24">
<h1 class="sr-only"><%= t(".confirm_title") %></h1>
<div class="text-center space-y-2 max-w-[400px] mx-auto mb-8">
<h2 class="text-3xl text-gray-900 font-medium"><%= t(".confirm_subtitle") %></h2>
<p class="text-gray-500 text-sm"><%= t(".confirm_description") %></p>
</div>
<div class="mb-8 space-y-4">
<%= render partial: "imports/transactions/transaction_group", collection: @import.dry_run.group_by(&:date) %>
</div>
<%= button_to "Import " + @import.csv.table.size.to_s + " transactions", confirm_import_path(@import), method: :patch, class: "px-4 py-2 block w-60 text-center mx-auto rounded-lg bg-gray-900 text-white text-sm font-medium", data: { turbo: false } %>
</div>

View File

@@ -0,0 +1,10 @@
<%= content_for :return_to_path, return_to_path(params, imports_path) %>
<div class="mx-auto max-w-[400px] w-full py-56">
<h1 class="sr-only"><%= t(".edit_title") %></h1>
<div class="space-y-2 mb-6 text-center">
<p class="text-3xl font-medium text-gray-900"><%= t(".header_text") %></p>
<p class="text-gray-500 text-sm"><%= t(".description_text") %></p>
</div>
<%= render "form", import: @import %>
</div>

View File

@@ -0,0 +1,30 @@
<% content_for :sidebar do %>
<%= render "settings/nav" %>
<% end %>
<div class="space-y-4">
<div class="flex items-center justify-between">
<h1 class="text-xl font-medium text-gray-900"><%= t(".title") %></h1>
<%= link_to new_import_path(enable_type_selector: true), class: "flex text-white text-sm font-medium items-center gap-1 bg-gray-900 rounded-lg p-2 pr-3", data: { turbo_frame: "modal" } do %>
<%= lucide_icon("plus", class: "w-5 h-5") %>
<span><%= t(".new") %></span>
<% end %>
</div>
<div class="bg-white shadow-xs border border-alpha-black-25 rounded-xl p-4">
<% if @imports.empty? %>
<%= render partial: "imports/empty" %>
<% else %>
<div class="rounded-xl bg-gray-25 p-1">
<h2 class="uppercase px-4 py-2 text-gray-500 text-xs"><%= t(".imports") %> · <%= @imports.size %></h2>
<div class="border border-alpha-gray-100 rounded-lg bg-white shadow-xs">
<%= render @imports.ordered %>
</div>
</div>
<% end %>
</div>
<div class="flex justify-between gap-4">
<%= previous_setting("Rules", transaction_rules_path) %>
<%= next_setting("What's new", changelog_path) %>
</div>
</div>

View File

@@ -0,0 +1,39 @@
<%= content_for :return_to_path, return_to_path(params, imports_path) %>
<div class="mx-auto max-w-[450px] w-full py-24 space-y-4">
<h1 class="sr-only"><%= t(".load_title") %></h1>
<div class="text-center space-y-2">
<h2 class="text-3xl text-gray-900 font-medium"><%= t(".subtitle") %></h2>
<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", 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>
<ul class="list-disc text-sm pl-10">
<li><%= t(".requirement1") %></li>
<li><%= t(".requirement2") %></li>
</ul>
</div>
<%= render partial: "imports/sample_table" %>
</div>
</div>

View File

@@ -0,0 +1,16 @@
<%= content_for :return_to_path, return_to_path(params, imports_path) %>
<% if params[:enable_type_selector].present? %>
<%= modal do %>
<%= render "type_selector" %>
<% end %>
<% end %>
<div class="mx-auto max-w-[400px] w-full py-56">
<h1 class="sr-only">New import</h1>
<div class="space-y-2 mb-6 text-center">
<p class="text-3xl font-medium text-gray-900"><%= t(".header_text") %></p>
<p class="text-gray-500 text-sm"><%= t(".description_text") %></p>
</div>
<%= render "form", import: @import %>
</div>

View File

@@ -0,0 +1,15 @@
<div class="mx-auto md:w-2/3 w-full flex">
<div class="mx-auto">
<% if notice.present? %>
<p class="py-2 px-3 bg-green-50 mb-5 text-green-500 font-medium rounded-lg inline-block" id="notice"><%= notice %></p>
<% end %>
<%= render @import %>
<%= link_to "Edit this import", edit_import_path(@import), class: "mt-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
<div class="inline-block ml-2">
<%= button_to "Destroy this import", import_path(@import), method: :delete, class: "mt-2 rounded-lg py-3 px-5 bg-gray-100 font-medium" %>
</div>
<%= link_to "Back to imports", imports_path, class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
</div>
</div>

View File

@@ -0,0 +1,12 @@
<%# locals: (transaction:) %>
<div class="text-gray-900 flex items-center gap-6 py-4 text-sm font-medium px-4">
<%= render partial: "transactions/transaction_name", locals: { name: transaction.name } %>
<div class="w-48">
<%= render partial: "transactions/categories/badge", locals: { category: transaction.category } %>
</div>
<div class="ml-auto">
<%= content_tag :p, format_money(-transaction.amount), class: ["whitespace-nowrap", BigDecimal(transaction.amount).negative? ? "text-green-600" : "text-red-600"] %>
</div>
</div>

View File

@@ -0,0 +1,13 @@
<%# locals: (transaction_group:) %>
<% date = transaction_group[0] %>
<% transactions = transaction_group[1] %>
<div class="bg-gray-25 rounded-xl p-1 w-full">
<div class="py-2 px-4 flex items-center justify-between font-medium text-xs text-gray-500">
<h4><%= date.strftime("%b %d, %Y") %> &middot; <%= transactions.size %></h4>
<span><%= format_money -transactions.sum { |t| t.amount } %></span>
</div>
<div class="bg-white shadow-xs rounded-md border border-alpha-black-25 divide-y divide-alpha-black-50">
<%= render partial: "imports/transactions/transaction", collection: transactions %>
</div>
</div>

View File

@@ -1,52 +1,39 @@
<!DOCTYPE html>
<html class="h-full bg-gray-25">
<html class="h-full">
<head>
<title><%= content_for(:title) || "Maybe" %></title>
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %>
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<%= javascript_importmap_tags %>
<%= hotwire_livereload_tags if Rails.env.development? %>
<%= turbo_refreshes_with method: :morph, scroll: :preserve %>
<meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Maybe">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="manifest" href="/site.webmanifest">
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#ffffff">
<meta name="msapplication-TileColor" content="#ffffff">
<meta name="theme-color" content="#ffffff">
<%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %>
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<%= javascript_importmap_tags %>
<%= hotwire_livereload_tags if Rails.env.development? %>
<%= turbo_refreshes_with method: :morph, scroll: :preserve %>
<%= yield :head %>
</head>
<body class="h-full">
<div id="notification-tray" class="fixed z-50 space-y-1 top-6 right-6"></div>
<%= safe_join(flash.map { |type, message| notification(message, type: type) }) %>
<div class="flex h-full">
<div class="p-6 pb-20 w-[368px] shrink-0 h-full overflow-y-auto">
<% if content_for?(:sidebar) %>
<%= yield :sidebar %>
<% else %>
<%= render "layouts/sidebar" %>
<% end %>
</div>
<main class="grow px-20 pt-6 pb-32 h-full overflow-y-auto">
<%= yield %>
</main>
</div>
<%= content_for?(:content) ? yield(:content) : yield %>
<%= turbo_frame_tag "modal" %>
<%= render "shared/confirm_modal" %>
<%= render "shared/upgrade_notification" %>
<% if self_hosted? %>
<div class="flex items-center py-0.5 px-0.5 gap-1 fixed bottom-2 right-2 shadow-xs border border-alpha-black-50 rounded-md bg-white">
<p class="text-xs text-gray-500 pl-2">Self-hosted Maybe: <%= Maybe.version.to_release_tag %></p>
<%= link_to settings_hosting_path, class: "flex gap-1 items-center hover:bg-gray-50 rounded-md p-2" do %>
<%= lucide_icon("settings", class: "w-4 h-4 text-gray-500 shrink-0") %>
<% end %>
</div>
<%= render "shared/app_version" %>
<% end %>
</body>
</html>

View File

@@ -1,61 +1,31 @@
<!DOCTYPE html>
<html class="h-full bg-white">
<head>
<title>Maybe</title>
<meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Maybe">
<%= content_for :content do %>
<div class="flex flex-col justify-center min-h-full px-6 py-12">
<div class="sm:mx-auto sm:w-full sm:max-w-md">
<%= render "shared/logo" %>
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="manifest" href="/site.webmanifest">
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#ffffff">
<meta name="msapplication-TileColor" content="#ffffff">
<meta name="theme-color" content="#ffffff">
<h2 class="mt-6 text-3xl font-semibold tracking-tight text-center font-display">
<%= content_for?(:header_title) ? yield(:header_title).html_safe : t(".your_account") %>
</h2>
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %>
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<%= javascript_importmap_tags %>
<%= hotwire_livereload_tags if Rails.env.development? %>
<%= turbo_refreshes_with method: :morph, scroll: :preserve %>
</head>
<body class="h-full">
<div class="flex flex-col justify-center min-h-full px-6 py-12">
<div class="sm:mx-auto sm:w-full sm:max-w-md">
<%= render "shared/logo" %>
<h2 class="mt-6 text-3xl font-semibold tracking-tight text-center font-display">
<%= content_for?(:header_title) ? yield(:header_title).html_safe : t(".your_account") %>
</h2>
<% if controller_name == "sessions" %>
<% if controller_name == "sessions" %>
<p class="mt-2 text-sm text-center text-gray-600">
<%= t(".or") %> <%= link_to t(".sign_up"), new_registration_path, class: "font-medium text-gray-600 hover:text-gray-400 transition" %>
</p>
<% elsif controller_name == "registrations" %>
<% elsif controller_name == "registrations" %>
<p class="mt-2 text-sm text-center text-gray-600">
<%= t(".or") %> <%= link_to t(".sign_in"), new_session_path, class: "font-medium text-gray-600 hover:text-gray-400 transition" %>
</p>
<% end %>
</div>
<div class="mt-8 sm:mx-auto sm:w-full sm:max-w-lg">
<%= yield %>
</div>
<div class="p-8 mt-2 text-center">
<p class="mt-6 text-sm text-black"><%= link_to t(".privacy_policy"), "/privacy", class: "font-medium text-gray-600 hover:text-gray-400 transition" %> &bull; <%= link_to t(".terms_of_service"), "/terms", class: "font-medium text-gray-600 hover:text-gray-400 transition" %></p>
</div>
<% end %>
</div>
</body>
</html>
<div class="mt-8 sm:mx-auto sm:w-full sm:max-w-lg">
<%= yield %>
</div>
<div class="p-8 mt-2 text-center">
<p class="mt-6 text-sm text-black"><%= link_to t(".privacy_policy"), "/privacy", class: "font-medium text-gray-600 hover:text-gray-400 transition" %> &bull; <%= link_to t(".terms_of_service"), "/terms", class: "font-medium text-gray-600 hover:text-gray-400 transition" %></p>
</div>
</div>
<% end %>
<%= render template: "layouts/application" %>

View File

@@ -0,0 +1,34 @@
<%= content_for :content do %>
<div class="flex items-center justify-between p-8">
<%= link_to root_path do %>
<%= image_tag "logo.svg", alt: "Maybe", class: "h-[22px]" %>
<% end %>
<nav>
<div>
<ul class="flex items-center gap-2">
<% nav_steps(@import).each_with_index do |step, idx| %>
<li class="group flex items-center gap-2">
<% if step[:path].present? %>
<%= link_to step[:path], class: "flex items-center gap-3" do %>
<%= render partial: "nav_step", locals: { step: step, step_idx: idx } %>
<% end %>
<% else %>
<%= render partial: "nav_step", locals: { step: step, step_idx: idx } %>
<% end %>
<% if idx < nav_steps.size %>
<div class="h-px bg-alpha-black-200 w-12 group-last:hidden"></div>
<% end %>
</li>
<% end %>
</ul>
</div>
</nav>
<%= link_to content_for(:return_to_path) do %>
<%= lucide_icon("x", class: "text-gray-500 w-5 h-5") %>
<% end %>
</div>
<%= yield %>
<% end %>
<%= render template: "layouts/application" %>

View File

@@ -0,0 +1,18 @@
<%= content_for :content do %>
<div class="flex h-full bg-gray-25">
<div class="p-6 pb-20 w-[368px] shrink-0 h-full overflow-y-auto">
<% if content_for?(:sidebar) %>
<%= yield :sidebar %>
<% else %>
<%= render "layouts/sidebar" %>
<% end %>
</div>
<main class="grow px-20 pt-6 pb-32 h-full overflow-y-auto">
<%= yield %>
</main>
</div>
<%= render "shared/upgrade_notification" %>
<% end %>
<%= render template: "layouts/application" %>

View File

@@ -9,7 +9,7 @@
</div>
</div>
<div class="flex justify-between gap-4">
<%= previous_setting("Rules", transaction_rules_path) %>
<%= previous_setting("Imports", imports_path) %>
<%= next_setting("Feedback", feedback_path) %>
</div>
</div>

View File

@@ -55,6 +55,9 @@
<li>
<%= sidebar_link_to t(".rules_label"), transaction_rules_path, icon: "list-checks" %>
</li>
<li>
<%= sidebar_link_to t(".imports_label"), imports_path, icon: "download" %>
</li>
</ul>
</section>
<section class="space-y-2">

View File

@@ -0,0 +1,6 @@
<div class="flex items-center py-0.5 px-0.5 gap-1 fixed bottom-2 right-2 shadow-xs border border-alpha-black-50 rounded-md bg-white">
<p class="text-xs text-gray-500 pl-2">Version: <%= Maybe.version.to_release_tag %></p>
<%= link_to settings_hosting_path, class: "flex gap-1 items-center hover:bg-gray-50 rounded-md p-2" do %>
<%= lucide_icon("settings", class: "w-4 h-4 text-gray-500 shrink-0") %>
<% end %>
</div>

View File

@@ -6,16 +6,28 @@
<%= contextual_menu do %>
<div class="w-48 p-1 text-sm leading-6 text-gray-900 bg-white shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
<%= link_to transaction_categories_path,
class: "block w-full py-2 px-3 space-x-2 text-gray-900 hover:bg-gray-50 flex items-center rounded-lg font-normal" do %>
class: "block w-full py-2 px-3 space-x-2 text-gray-900 hover:bg-gray-50 flex items-center rounded-lg font-normal" do %>
<%= lucide_icon "tags", class: "w-5 h-5 text-gray-500" %>
<span class="text-black"><%= t(".edit_categories") %></span>
<% end %>
<%= link_to imports_path,
class: "block w-full py-2 px-3 space-x-2 text-gray-900 hover:bg-gray-50 flex items-center rounded-lg font-normal" do %>
<%= lucide_icon "hard-drive-upload", class: "w-5 h-5 text-gray-500" %>
<span class="text-black"><%= t(".edit_imports") %></span>
<% end %>
</div>
<% end %>
<%= link_to new_import_path(enable_type_selector: true), class: "rounded-lg bg-gray-50 border border-gray-200 flex items-center gap-1 justify-center px-3 py-2", data: { turbo_frame: "modal" } do %>
<%= lucide_icon("download", class: "text-gray-500 w-4 h-4") %>
<p class="text-sm font-medium text-gray-900"><%= t(".import") %></p>
<% end %>
<%= link_to new_transaction_path, class: "rounded-lg bg-gray-900 text-white flex items-center gap-1 justify-center hover:bg-gray-700 px-3 py-2", data: { turbo_frame: :modal } do %>
<%= lucide_icon("plus", class: "w-5 h-5") %>
<p>New transaction</p>
<p class="text-sm font-medium">New transaction</p>
<% end %>
</div>
</div>

View File

@@ -10,6 +10,6 @@
</div>
<div class="flex justify-between gap-4">
<%= previous_setting("Merchants", transaction_merchants_path) %>
<%= next_setting("What's New", changelog_path) %>
<%= next_setting("Imports", imports_path) %>
</div>
</div>