mirror of
https://github.com/we-promise/sure.git
synced 2026-04-19 12:04:08 +00:00
Merge branch 'main' into add-config-import-csv-skip-first-x-rows
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
This commit is contained in:
280
app/views/admin/sso_providers/_form.html.erb
Normal file
280
app/views/admin/sso_providers/_form.html.erb
Normal file
@@ -0,0 +1,280 @@
|
||||
<%# locals: (sso_provider:) %>
|
||||
|
||||
<% if sso_provider.errors.any? %>
|
||||
<div class="bg-destructive/10 border border-destructive rounded-lg p-4 mb-4">
|
||||
<div class="flex">
|
||||
<%= icon "alert-circle", class: "w-5 h-5 text-destructive mr-2 shrink-0" %>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-destructive">
|
||||
<%= pluralize(sso_provider.errors.count, "error") %> prohibited this provider from being saved:
|
||||
</p>
|
||||
<ul class="mt-2 text-sm text-destructive list-disc list-inside">
|
||||
<% sso_provider.errors.full_messages.each do |message| %>
|
||||
<li><%= message %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= styled_form_with model: [:admin, sso_provider], class: "space-y-6", data: { controller: "admin-sso-form" } do |form| %>
|
||||
<div class="space-y-4">
|
||||
<h3 class="font-medium text-primary">Basic Information</h3>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<%= form.select :strategy,
|
||||
options_for_select([
|
||||
["OpenID Connect", "openid_connect"],
|
||||
["SAML 2.0", "saml"],
|
||||
["Google OAuth2", "google_oauth2"],
|
||||
["GitHub", "github"]
|
||||
], sso_provider.strategy),
|
||||
{ label: "Strategy" },
|
||||
{ data: { action: "change->admin-sso-form#toggleFields" } } %>
|
||||
|
||||
<%= form.text_field :name,
|
||||
label: "Name",
|
||||
placeholder: "e.g., keycloak, authentik",
|
||||
required: true,
|
||||
data: { action: "input->admin-sso-form#updateCallbackUrl" } %>
|
||||
</div>
|
||||
<p class="text-xs text-secondary -mt-2">Unique identifier (lowercase, numbers, underscores only)</p>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<%= form.text_field :label,
|
||||
label: "Button Label",
|
||||
placeholder: "e.g., Sign in with Keycloak",
|
||||
required: true %>
|
||||
|
||||
<div>
|
||||
<%= form.text_field :icon,
|
||||
label: "Icon (optional)",
|
||||
placeholder: "e.g., key, shield" %>
|
||||
<p class="text-xs text-secondary mt-1">Lucide icon name for the login button</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium text-primary"><%= t("admin.sso_providers.form.enabled_label") %></p>
|
||||
<p class="text-xs text-secondary"><%= t("admin.sso_providers.form.enabled_help") %></p>
|
||||
</div>
|
||||
<%= form.toggle :enabled %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-primary pt-4 space-y-4">
|
||||
<h3 class="font-medium text-primary">OAuth/OIDC Configuration</h3>
|
||||
|
||||
<div data-oidc-field class="<%= "hidden" unless sso_provider.strategy == "openid_connect" %>">
|
||||
<%= form.text_field :issuer,
|
||||
label: "Issuer URL",
|
||||
placeholder: "https://your-idp.example.com/realms/your-realm",
|
||||
data: { action: "blur->admin-sso-form#validateIssuer" } %>
|
||||
<p class="text-xs text-secondary mt-1">OIDC issuer URL (validates .well-known/openid-configuration)</p>
|
||||
</div>
|
||||
|
||||
<%= form.text_field :client_id,
|
||||
label: "Client ID",
|
||||
placeholder: "your-client-id",
|
||||
required: true %>
|
||||
|
||||
<%= form.password_field :client_secret,
|
||||
label: "Client Secret",
|
||||
placeholder: sso_provider.persisted? ? "••••••••" : "your-client-secret",
|
||||
required: !sso_provider.persisted? %>
|
||||
<% if sso_provider.persisted? %>
|
||||
<p class="text-xs text-secondary -mt-2">Leave blank to keep existing secret</p>
|
||||
<% end %>
|
||||
|
||||
<div data-oidc-field class="<%= "hidden" unless sso_provider.strategy == "openid_connect" %>">
|
||||
<label class="block text-sm font-medium text-primary mb-1">Callback URL</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="flex-1 bg-surface px-3 py-2 rounded text-sm text-secondary overflow-x-auto"
|
||||
data-admin-sso-form-target="callbackUrl"><%= "#{request.base_url}/auth/#{sso_provider.name.presence || 'PROVIDER_NAME'}/callback" %></code>
|
||||
<button type="button"
|
||||
data-action="click->admin-sso-form#copyCallback"
|
||||
class="p-2 text-secondary hover:text-primary shrink-0"
|
||||
title="Copy to clipboard">
|
||||
<%= icon "copy", class: "w-4 h-4" %>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-secondary mt-1">Configure this URL in your identity provider</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-saml-field class="border-t border-primary pt-4 space-y-4 <%= "hidden" unless sso_provider.strategy == "saml" %>">
|
||||
<h3 class="font-medium text-primary"><%= t("admin.sso_providers.form.saml_configuration") %></h3>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-primary mb-1"><%= t("admin.sso_providers.form.idp_metadata_url") %></label>
|
||||
<input type="text" name="sso_provider[settings][idp_metadata_url]"
|
||||
value="<%= sso_provider.settings&.dig("idp_metadata_url") %>"
|
||||
class="w-full px-3 py-2 border border-primary rounded-lg text-sm"
|
||||
placeholder="https://idp.example.com/metadata"
|
||||
autocomplete="off">
|
||||
<p class="text-xs text-secondary mt-1"><%= t("admin.sso_providers.form.idp_metadata_url_help") %></p>
|
||||
</div>
|
||||
|
||||
<details class="mt-4">
|
||||
<summary class="cursor-pointer text-sm font-medium text-secondary hover:text-primary"><%= t("admin.sso_providers.form.manual_saml_config") %></summary>
|
||||
<div class="mt-3 space-y-3 pl-4 border-l-2 border-secondary/30">
|
||||
<p class="text-xs text-secondary"><%= t("admin.sso_providers.form.manual_saml_help") %></p>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-primary mb-1"><%= t("admin.sso_providers.form.idp_sso_url") %></label>
|
||||
<input type="text" name="sso_provider[settings][idp_sso_url]"
|
||||
value="<%= sso_provider.settings&.dig("idp_sso_url") %>"
|
||||
class="w-full px-3 py-2 border border-primary rounded-lg text-sm"
|
||||
placeholder="https://idp.example.com/sso"
|
||||
autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-primary mb-1"><%= t("admin.sso_providers.form.idp_slo_url") %></label>
|
||||
<input type="text" name="sso_provider[settings][idp_slo_url]"
|
||||
value="<%= sso_provider.settings&.dig("idp_slo_url") %>"
|
||||
class="w-full px-3 py-2 border border-primary rounded-lg text-sm"
|
||||
placeholder="https://idp.example.com/slo (optional)"
|
||||
autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-primary mb-1"><%= t("admin.sso_providers.form.idp_certificate") %></label>
|
||||
<textarea name="sso_provider[settings][idp_certificate]"
|
||||
rows="4"
|
||||
class="w-full px-3 py-2 border border-primary rounded-lg text-sm font-mono"
|
||||
placeholder="-----BEGIN CERTIFICATE-----"><%= sso_provider.settings&.dig("idp_certificate") %></textarea>
|
||||
<p class="text-xs text-secondary mt-1"><%= t("admin.sso_providers.form.idp_certificate_help") %></p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-primary mb-1"><%= t("admin.sso_providers.form.idp_cert_fingerprint") %></label>
|
||||
<input type="text" name="sso_provider[settings][idp_cert_fingerprint]"
|
||||
value="<%= sso_provider.settings&.dig("idp_cert_fingerprint") %>"
|
||||
class="w-full px-3 py-2 border border-primary rounded-lg text-sm font-mono"
|
||||
placeholder="AB:CD:EF:..."
|
||||
autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-primary mb-1"><%= t("admin.sso_providers.form.name_id_format") %></label>
|
||||
<select name="sso_provider[settings][name_id_format]"
|
||||
class="w-full px-3 py-2 border border-primary rounded-lg text-sm">
|
||||
<option value="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" <%= "selected" if sso_provider.settings&.dig("name_id_format").blank? || sso_provider.settings&.dig("name_id_format") == "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" %>><%= t("admin.sso_providers.form.name_id_email") %></option>
|
||||
<option value="urn:oasis:names:tc:SAML:2.0:nameid-format:persistent" <%= "selected" if sso_provider.settings&.dig("name_id_format") == "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent" %>><%= t("admin.sso_providers.form.name_id_persistent") %></option>
|
||||
<option value="urn:oasis:names:tc:SAML:2.0:nameid-format:transient" <%= "selected" if sso_provider.settings&.dig("name_id_format") == "urn:oasis:names:tc:SAML:2.0:nameid-format:transient" %>><%= t("admin.sso_providers.form.name_id_transient") %></option>
|
||||
<option value="urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified" <%= "selected" if sso_provider.settings&.dig("name_id_format") == "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified" %>><%= t("admin.sso_providers.form.name_id_unspecified") %></option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-primary mb-1">SP Callback URL (ACS URL)</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="flex-1 bg-surface px-3 py-2 rounded text-sm text-secondary overflow-x-auto"
|
||||
data-admin-sso-form-target="samlCallbackUrl"><%= "#{request.base_url}/auth/#{sso_provider.name.presence || 'PROVIDER_NAME'}/callback" %></code>
|
||||
<button type="button"
|
||||
data-action="click->admin-sso-form#copySamlCallback"
|
||||
class="p-2 text-secondary hover:text-primary shrink-0"
|
||||
title="Copy to clipboard">
|
||||
<%= icon "copy", class: "w-4 h-4" %>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-secondary mt-1">Configure this URL as the Assertion Consumer Service URL in your IdP</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-primary pt-4 space-y-4">
|
||||
<h3 class="font-medium text-primary"><%= t("admin.sso_providers.form.provisioning_title") %></h3>
|
||||
|
||||
<%= form.select "settings[default_role]",
|
||||
options_for_select([
|
||||
[t("admin.sso_providers.form.role_member"), "member"],
|
||||
[t("admin.sso_providers.form.role_admin"), "admin"],
|
||||
[t("admin.sso_providers.form.role_super_admin"), "super_admin"]
|
||||
], sso_provider.settings&.dig("default_role") || "member"),
|
||||
{ label: t("admin.sso_providers.form.default_role_label"), include_blank: false } %>
|
||||
<p class="text-xs text-secondary -mt-2"><%= t("admin.sso_providers.form.default_role_help") %></p>
|
||||
|
||||
<details class="mt-4">
|
||||
<summary class="cursor-pointer text-sm font-medium text-secondary hover:text-primary"><%= t("admin.sso_providers.form.role_mapping_title") %></summary>
|
||||
<div class="mt-3 space-y-3 pl-4 border-l-2 border-secondary/30">
|
||||
<p class="text-xs text-secondary"><%= t("admin.sso_providers.form.role_mapping_help") %></p>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-primary mb-1"><%= t("admin.sso_providers.form.super_admin_groups") %></label>
|
||||
<input type="text" name="sso_provider[settings][role_mapping][super_admin]"
|
||||
value="<%= Array(sso_provider.settings&.dig("role_mapping", "super_admin")).join(", ") %>"
|
||||
class="w-full px-3 py-2 border border-primary rounded-lg text-sm"
|
||||
placeholder="Platform-Admins, IdP-Superusers"
|
||||
autocomplete="off">
|
||||
<p class="text-xs text-secondary mt-1"><%= t("admin.sso_providers.form.groups_help") %></p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-primary mb-1"><%= t("admin.sso_providers.form.admin_groups") %></label>
|
||||
<input type="text" name="sso_provider[settings][role_mapping][admin]"
|
||||
value="<%= Array(sso_provider.settings&.dig("role_mapping", "admin")).join(", ") %>"
|
||||
class="w-full px-3 py-2 border border-primary rounded-lg text-sm"
|
||||
placeholder="Team-Leads, Managers"
|
||||
autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-primary mb-1"><%= t("admin.sso_providers.form.member_groups") %></label>
|
||||
<input type="text" name="sso_provider[settings][role_mapping][member]"
|
||||
value="<%= Array(sso_provider.settings&.dig("role_mapping", "member")).join(", ") %>"
|
||||
class="w-full px-3 py-2 border border-primary rounded-lg text-sm"
|
||||
placeholder="* (all groups)"
|
||||
autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div data-oidc-field class="border-t border-primary pt-4 space-y-4 <%= "hidden" unless sso_provider.strategy == "openid_connect" %>">
|
||||
<h3 class="font-medium text-primary"><%= t("admin.sso_providers.form.advanced_title") %></h3>
|
||||
|
||||
<div>
|
||||
<%= form.text_field "settings[scopes]",
|
||||
label: t("admin.sso_providers.form.scopes_label"),
|
||||
value: sso_provider.settings&.dig("scopes"),
|
||||
placeholder: "openid email profile groups" %>
|
||||
<p class="text-xs text-secondary mt-1"><%= t("admin.sso_providers.form.scopes_help") %></p>
|
||||
</div>
|
||||
|
||||
<%= form.select "settings[prompt]",
|
||||
options_for_select([
|
||||
[t("admin.sso_providers.form.prompt_default"), ""],
|
||||
[t("admin.sso_providers.form.prompt_login"), "login"],
|
||||
[t("admin.sso_providers.form.prompt_consent"), "consent"],
|
||||
[t("admin.sso_providers.form.prompt_select_account"), "select_account"],
|
||||
[t("admin.sso_providers.form.prompt_none"), "none"]
|
||||
], sso_provider.settings&.dig("prompt")),
|
||||
{ label: t("admin.sso_providers.form.prompt_label"), include_blank: false } %>
|
||||
<p class="text-xs text-secondary -mt-2"><%= t("admin.sso_providers.form.prompt_help") %></p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center gap-3 pt-4 border-t border-primary">
|
||||
<div>
|
||||
<% if sso_provider.persisted? %>
|
||||
<button type="button"
|
||||
data-action="click->admin-sso-form#testConnection"
|
||||
data-admin-sso-form-test-url-value="<%= test_connection_admin_sso_provider_path(sso_provider) %>"
|
||||
class="px-4 py-2 text-sm font-medium text-secondary hover:text-primary border border-secondary rounded-lg">
|
||||
<%= t("admin.sso_providers.form.test_connection") %>
|
||||
</button>
|
||||
<span data-admin-sso-form-target="testResult" class="ml-2 text-sm"></span>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<%= link_to "Cancel", admin_sso_providers_path, class: "px-4 py-2 text-sm font-medium text-secondary hover:text-primary" %>
|
||||
<%= form.submit sso_provider.persisted? ? "Update Provider" : "Create Provider",
|
||||
class: "px-4 py-2 bg-primary text-inverse rounded-lg text-sm font-medium hover:bg-primary/90" %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
9
app/views/admin/sso_providers/edit.html.erb
Normal file
9
app/views/admin/sso_providers/edit.html.erb
Normal file
@@ -0,0 +1,9 @@
|
||||
<%= content_for :page_title, "Edit #{@sso_provider.label}" %>
|
||||
|
||||
<div class="space-y-4">
|
||||
<p class="text-secondary">Update configuration for <%= @sso_provider.label %>.</p>
|
||||
|
||||
<%= settings_section title: "Provider Configuration" do %>
|
||||
<%= render "form", sso_provider: @sso_provider %>
|
||||
<% end %>
|
||||
</div>
|
||||
126
app/views/admin/sso_providers/index.html.erb
Normal file
126
app/views/admin/sso_providers/index.html.erb
Normal file
@@ -0,0 +1,126 @@
|
||||
<%= content_for :page_title, "SSO Providers" %>
|
||||
|
||||
<div class="space-y-4">
|
||||
<p class="text-secondary mb-4">
|
||||
Manage single sign-on authentication providers for your instance.
|
||||
<% unless Flipper.enabled?(:db_sso_providers) %>
|
||||
<span class="text-warning">Changes require a server restart to take effect.</span>
|
||||
<% end %>
|
||||
</p>
|
||||
|
||||
<%= settings_section title: "Configured Providers" do %>
|
||||
<% if @sso_providers.any? %>
|
||||
<div class="divide-y divide-primary">
|
||||
<% @sso_providers.each do |provider| %>
|
||||
<div class="flex items-center justify-between py-3 first:pt-0 last:pb-0">
|
||||
<div class="flex items-center gap-3">
|
||||
<% if provider.icon.present? %>
|
||||
<%= icon provider.icon, class: "w-5 h-5 text-secondary" %>
|
||||
<% else %>
|
||||
<%= icon "key", class: "w-5 h-5 text-secondary" %>
|
||||
<% end %>
|
||||
<div>
|
||||
<p class="font-medium text-primary"><%= provider.label %></p>
|
||||
<p class="text-sm text-secondary"><%= provider.strategy.titleize %> · <%= provider.name %></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<% if provider.enabled? %>
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">
|
||||
Enabled
|
||||
</span>
|
||||
<% else %>
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-surface text-secondary">
|
||||
Disabled
|
||||
</span>
|
||||
<% end %>
|
||||
<%= link_to edit_admin_sso_provider_path(provider), class: "p-1 text-secondary hover:text-primary", title: "Edit" do %>
|
||||
<%= icon "pencil", class: "w-4 h-4" %>
|
||||
<% end %>
|
||||
<%= button_to toggle_admin_sso_provider_path(provider), method: :patch, class: "p-1 text-secondary hover:text-primary", title: provider.enabled? ? "Disable" : "Enable", form: { data: { turbo_confirm: "Are you sure you want to #{provider.enabled? ? 'disable' : 'enable'} this provider?" } } do %>
|
||||
<%= icon provider.enabled? ? "toggle-right" : "toggle-left", class: "w-4 h-4" %>
|
||||
<% end %>
|
||||
<%= button_to admin_sso_provider_path(provider), method: :delete, class: "p-1 text-destructive hover:text-destructive", title: "Delete", form: { data: { turbo_confirm: "Are you sure you want to delete this provider? This action cannot be undone." } } do %>
|
||||
<%= icon "trash-2", class: "w-4 h-4" %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="text-center py-6">
|
||||
<%= icon "key", class: "w-12 h-12 mx-auto text-secondary mb-3" %>
|
||||
<p class="text-secondary">No SSO providers configured yet.</p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="pt-4 border-t border-primary">
|
||||
<%= link_to new_admin_sso_provider_path, class: "inline-flex items-center gap-2 text-sm font-medium text-primary hover:text-secondary" do %>
|
||||
<%= icon "plus", class: "w-4 h-4" %>
|
||||
Add Provider
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if @legacy_providers.any? %>
|
||||
<%= settings_section title: t("admin.sso_providers.index.legacy_providers_title"), collapsible: true, open: true do %>
|
||||
<div class="bg-amber-50 border border-amber-200 rounded-lg p-3 mb-4">
|
||||
<div class="flex gap-2">
|
||||
<%= icon "alert-triangle", class: "w-5 h-5 text-amber-600 shrink-0" %>
|
||||
<p class="text-sm text-amber-800">
|
||||
<%= t("admin.sso_providers.index.legacy_providers_notice") %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divide-y divide-primary">
|
||||
<% @legacy_providers.each do |provider| %>
|
||||
<div class="flex items-center justify-between py-3 first:pt-0 last:pb-0">
|
||||
<div class="flex items-center gap-3">
|
||||
<% provider_icon = provider[:icon].presence || "key" %>
|
||||
<%= icon provider_icon, class: "w-5 h-5 text-secondary" %>
|
||||
<div>
|
||||
<p class="font-medium text-primary"><%= provider[:label].presence || provider[:name] %></p>
|
||||
<p class="text-sm text-secondary">
|
||||
<%= provider[:strategy].to_s.titleize %> · <%= provider[:name] %>
|
||||
<% if provider[:issuer].present? %>
|
||||
· <span class="text-xs"><%= provider[:issuer] %></span>
|
||||
<% end %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-800">
|
||||
<%= t("admin.sso_providers.index.env_configured") %>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<%= settings_section title: "Configuration Mode", collapsible: true, open: false do %>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium text-primary">Database-backed providers</p>
|
||||
<p class="text-sm text-secondary">Load providers from database instead of YAML config</p>
|
||||
</div>
|
||||
<% if Flipper.enabled?(:db_sso_providers) %>
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">
|
||||
Enabled
|
||||
</span>
|
||||
<% else %>
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-surface text-secondary">
|
||||
Disabled
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<p class="text-sm text-secondary">
|
||||
Set <code class="bg-surface px-1 py-0.5 rounded text-xs">AUTH_PROVIDERS_SOURCE=db</code> to enable database-backed providers.
|
||||
This allows changes without server restarts.
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
9
app/views/admin/sso_providers/new.html.erb
Normal file
9
app/views/admin/sso_providers/new.html.erb
Normal file
@@ -0,0 +1,9 @@
|
||||
<%= content_for :page_title, "Add SSO Provider" %>
|
||||
|
||||
<div class="space-y-4">
|
||||
<p class="text-secondary">Configure a new single sign-on authentication provider.</p>
|
||||
|
||||
<%= settings_section title: "Provider Configuration" do %>
|
||||
<%= render "form", sso_provider: @sso_provider %>
|
||||
<% end %>
|
||||
</div>
|
||||
73
app/views/admin/users/index.html.erb
Normal file
73
app/views/admin/users/index.html.erb
Normal file
@@ -0,0 +1,73 @@
|
||||
<%= content_for :page_title, t(".title") %>
|
||||
|
||||
<div class="space-y-4">
|
||||
<p class="text-secondary"><%= t(".description") %></p>
|
||||
|
||||
<%= settings_section title: t(".section_title") do %>
|
||||
<div class="divide-y divide-primary">
|
||||
<% @users.each do |user| %>
|
||||
<div class="flex items-center justify-between py-3 first:pt-0 last:pb-0">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-full bg-surface flex items-center justify-center">
|
||||
<span class="text-sm font-medium text-primary"><%= user.initials %></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-primary"><%= user.display_name %></p>
|
||||
<p class="text-sm text-secondary"><%= user.email %></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<% if user.id == Current.user.id %>
|
||||
<span class="text-sm text-secondary"><%= t(".you") %></span>
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-surface text-primary">
|
||||
<%= t(".roles.#{user.role}") %>
|
||||
</span>
|
||||
<% else %>
|
||||
<%= form_with model: [:admin, user], method: :patch, class: "flex items-center gap-2" do |form| %>
|
||||
<%= form.select :role,
|
||||
options_for_select([
|
||||
[t(".roles.member"), "member"],
|
||||
[t(".roles.admin"), "admin"],
|
||||
[t(".roles.super_admin"), "super_admin"]
|
||||
], user.role),
|
||||
{},
|
||||
class: "text-sm rounded-lg border-primary bg-container text-primary px-2 py-1",
|
||||
onchange: "this.form.requestSubmit()" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if @users.empty? %>
|
||||
<div class="text-center py-6">
|
||||
<%= icon "users", class: "w-12 h-12 mx-auto text-secondary mb-3" %>
|
||||
<p class="text-secondary"><%= t(".no_users") %></p>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<%= settings_section title: t(".role_descriptions_title"), collapsible: true, open: false do %>
|
||||
<div class="space-y-3 text-sm">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-surface text-primary shrink-0">
|
||||
<%= t(".roles.member") %>
|
||||
</span>
|
||||
<p class="text-secondary"><%= t(".role_descriptions.member") %></p>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-surface text-primary shrink-0">
|
||||
<%= t(".roles.admin") %>
|
||||
</span>
|
||||
<p class="text-secondary"><%= t(".role_descriptions.admin") %></p>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 shrink-0">
|
||||
<%= t(".roles.super_admin") %>
|
||||
</span>
|
||||
<p class="text-secondary"><%= t(".role_descriptions.super_admin") %></p>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
109
app/views/holdings/_cost_basis_cell.html.erb
Normal file
109
app/views/holdings/_cost_basis_cell.html.erb
Normal file
@@ -0,0 +1,109 @@
|
||||
<%# locals: (holding:, editable: true) %>
|
||||
|
||||
<%
|
||||
# Pre-calculate values for the form
|
||||
# Note: cost_basis field stores per-share cost, so calculate total for display
|
||||
current_per_share = holding.cost_basis.present? && holding.cost_basis.positive? ? holding.cost_basis : nil
|
||||
current_total = current_per_share && holding.qty.positive? ? (current_per_share * holding.qty).round(2) : nil
|
||||
currency = Money::Currency.new(holding.currency)
|
||||
%>
|
||||
|
||||
<%= turbo_frame_tag dom_id(holding, :cost_basis) do %>
|
||||
<% if holding.cost_basis_locked? && !editable %>
|
||||
<%# Locked and not editable (from holdings list) - just show value, right-aligned %>
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<%= tag.span format_money(holding.avg_cost) %>
|
||||
<%= icon "lock", size: "xs", class: "text-secondary" %>
|
||||
</div>
|
||||
<% else %>
|
||||
<%# Unlocked OR editable context (drawer) - show clickable menu %>
|
||||
<%= render DS::Menu.new(variant: :button, placement: "bottom-end") do |menu| %>
|
||||
<% menu.with_button(class: "hover:text-primary cursor-pointer group") do %>
|
||||
<% if holding.avg_cost %>
|
||||
<div class="flex items-center gap-1">
|
||||
<%= tag.span format_money(holding.avg_cost) %>
|
||||
<% if holding.cost_basis_locked? %>
|
||||
<%= icon "lock", size: "xs", class: "text-secondary" %>
|
||||
<% end %>
|
||||
<%= icon "pencil", size: "xs", class: "text-secondary opacity-0 group-hover:opacity-100 transition-opacity" %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="flex items-center gap-1 px-2 py-0.5 rounded text-secondary hover:text-primary hover:bg-gray-100 theme-dark:hover:bg-gray-700 transition-colors">
|
||||
<%= icon "pencil", size: "xs" %>
|
||||
<span class="text-xs">Set</span>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% menu.with_custom_content do %>
|
||||
<div class="p-4 min-w-[280px]"
|
||||
data-controller="cost-basis-form"
|
||||
data-cost-basis-form-qty-value="<%= holding.qty %>">
|
||||
<h4 class="font-medium text-sm mb-3">
|
||||
<%= t(".set_cost_basis_header", ticker: holding.ticker, qty: number_with_precision(holding.qty, precision: 2)) %>
|
||||
</h4>
|
||||
<%
|
||||
form_data = { turbo: false }
|
||||
if holding.avg_cost
|
||||
form_data[:turbo_confirm] = {
|
||||
title: t(".overwrite_confirm_title"),
|
||||
body: t(".overwrite_confirm_body", current: format_money(holding.avg_cost))
|
||||
}
|
||||
end
|
||||
%>
|
||||
<%= styled_form_with model: holding,
|
||||
url: holding_path(holding),
|
||||
method: :patch,
|
||||
class: "space-y-3",
|
||||
data: form_data do |f| %>
|
||||
<!-- Primary: Total cost basis (custom input, no spinners) -->
|
||||
<div class="form-field">
|
||||
<div class="form-field__body">
|
||||
<label class="form-field__label"><%= t(".total_cost_basis_label") %></label>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-secondary text-sm font-medium"><%= currency.symbol %></span>
|
||||
<input type="text" inputmode="decimal"
|
||||
name="holding[cost_basis]"
|
||||
class="form-field__input grow"
|
||||
placeholder="0.00"
|
||||
autocomplete="off"
|
||||
value="<%= number_with_precision(current_total, precision: 2) if current_total %>"
|
||||
data-action="input->cost-basis-form#updatePerShare"
|
||||
data-cost-basis-form-target="total">
|
||||
<span class="text-secondary text-sm"><%= currency.iso_code %></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-secondary -mt-2" data-cost-basis-form-target="perShareDisplay">
|
||||
= <%= currency.symbol %><span data-cost-basis-form-target="perShareValue"><%= number_with_precision(current_per_share, precision: 2) || "0.00" %></span> <%= t(".per_share") %>
|
||||
</p>
|
||||
|
||||
<!-- Alternative: Per-share input -->
|
||||
<div class="pt-2 border-t border-tertiary">
|
||||
<label class="text-xs text-secondary block mb-1"><%= t(".or_per_share_label") %></label>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-secondary text-sm font-medium"><%= currency.symbol %></span>
|
||||
<input type="text" inputmode="decimal"
|
||||
class="form-field__input grow"
|
||||
placeholder="0.00"
|
||||
autocomplete="off"
|
||||
value="<%= number_with_precision(current_per_share, precision: 2) if current_per_share %>"
|
||||
data-action="input->cost-basis-form#updateTotal"
|
||||
data-cost-basis-form-target="perShare">
|
||||
<span class="text-secondary text-sm"><%= currency.iso_code %></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<button type="button"
|
||||
class="inline-flex items-center gap-1 px-2 py-1 rounded-md text-sm font-medium text-primary bg-gray-200 hover:bg-gray-300 theme-dark:bg-gray-700 theme-dark:hover:bg-gray-600"
|
||||
data-action="click->DS--menu#close">
|
||||
<%= t(".cancel") %>
|
||||
</button>
|
||||
<%= f.submit t(".save"), class: "inline-flex items-center gap-1 px-2 py-1 rounded-md text-sm font-medium text-inverse bg-inverse hover:bg-inverse-hover" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
@@ -31,7 +31,7 @@
|
||||
</div>
|
||||
|
||||
<div class="col-span-2 text-right">
|
||||
<%= tag.p format_money holding.avg_cost %>
|
||||
<%= render "holdings/cost_basis_cell", holding: holding, editable: false %>
|
||||
<%= tag.p t(".per_share"), class: "font-normal text-secondary" %>
|
||||
</div>
|
||||
|
||||
@@ -45,13 +45,13 @@
|
||||
</div>
|
||||
|
||||
<div class="col-span-2 text-right">
|
||||
<%# Show Total Return (unrealized G/L) when cost basis exists %>
|
||||
<% if holding.trades.any? && holding.trend %>
|
||||
<%# Show Total Return (unrealized G/L) when cost basis exists (from trades or manual) %>
|
||||
<% if holding.trend %>
|
||||
<%= tag.p format_money(holding.trend.value), style: "color: #{holding.trend.color};" %>
|
||||
<%= tag.p "(#{number_to_percentage(holding.trend.percent, precision: 1)})", style: "color: #{holding.trend.color};" %>
|
||||
<% else %>
|
||||
<%= tag.p "--", class: "text-secondary" %>
|
||||
<%= tag.p "No cost basis", class: "text-xs text-secondary" %>
|
||||
<%= tag.p t(".no_cost_basis"), class: "text-xs text-secondary" %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -35,16 +35,107 @@
|
||||
<dd class="text-primary"><%= @holding.weight ? number_to_percentage(@holding.weight, precision: 2) : t(".unknown") %></dd>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<dt class="text-secondary"><%= t(".avg_cost_label") %></dt>
|
||||
<dd class="text-primary"><%= @holding.avg_cost ? format_money(@holding.avg_cost) : t(".unknown") %></dd>
|
||||
<%# Average Cost with inline editor %>
|
||||
<%
|
||||
currency = Money::Currency.new(@holding.currency)
|
||||
current_per_share = @holding.cost_basis.present? && @holding.cost_basis.positive? ? @holding.cost_basis : nil
|
||||
current_total = current_per_share && @holding.qty.positive? ? (current_per_share * @holding.qty).round(2) : nil
|
||||
%>
|
||||
<div data-controller="drawer-cost-basis" data-drawer-cost-basis-qty-value="<%= @holding.qty %>">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<dt class="text-secondary"><%= t(".avg_cost_label") %></dt>
|
||||
<dd class="text-primary flex items-center gap-1">
|
||||
<%= @holding.avg_cost ? format_money(@holding.avg_cost) : t(".unknown") %>
|
||||
<% if @holding.cost_basis_locked? %>
|
||||
<%= icon "lock", size: "xs", class: "text-secondary" %>
|
||||
<% end %>
|
||||
<% if @holding.cost_basis_source.present? %>
|
||||
<span class="text-xs text-secondary">(<%= @holding.cost_basis_source_label %>)</span>
|
||||
<% end %>
|
||||
<button type="button" class="ml-1" data-action="click->drawer-cost-basis#toggle">
|
||||
<%= icon "pencil", size: "xs", class: "text-secondary hover:text-primary" %>
|
||||
</button>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<%# Inline cost basis editor (hidden by default) %>
|
||||
<div class="hidden mt-3 space-y-3" data-drawer-cost-basis-target="form">
|
||||
<%
|
||||
drawer_form_data = { turbo: false }
|
||||
if @holding.avg_cost
|
||||
drawer_form_data[:turbo_confirm] = {
|
||||
title: t("holdings.cost_basis_cell.overwrite_confirm_title"),
|
||||
body: t("holdings.cost_basis_cell.overwrite_confirm_body", current: format_money(@holding.avg_cost))
|
||||
}
|
||||
end
|
||||
%>
|
||||
<%= styled_form_with model: @holding,
|
||||
url: holding_path(@holding),
|
||||
method: :patch,
|
||||
class: "space-y-3",
|
||||
data: drawer_form_data do |f| %>
|
||||
<p class="text-xs text-secondary mb-2">
|
||||
<%= t("holdings.cost_basis_cell.set_cost_basis_header", ticker: @holding.ticker, qty: number_with_precision(@holding.qty, precision: 4)) %>
|
||||
</p>
|
||||
<!-- Total cost basis input -->
|
||||
<div class="form-field">
|
||||
<div class="form-field__body">
|
||||
<label class="form-field__label"><%= t("holdings.cost_basis_cell.total_cost_basis_label") %></label>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-secondary text-sm font-medium"><%= currency.symbol %></span>
|
||||
<input type="text" inputmode="decimal"
|
||||
name="holding[cost_basis]"
|
||||
class="form-field__input grow"
|
||||
placeholder="0.00"
|
||||
autocomplete="off"
|
||||
value="<%= number_with_precision(current_total, precision: 2) if current_total %>"
|
||||
data-action="input->drawer-cost-basis#updatePerShare"
|
||||
data-drawer-cost-basis-target="total">
|
||||
<span class="text-secondary text-sm"><%= currency.iso_code %></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-secondary -mt-2">
|
||||
= <%= currency.symbol %><span data-drawer-cost-basis-target="perShareValue"><%= number_with_precision(current_per_share, precision: 2) || "0.00" %></span> <%= t("holdings.cost_basis_cell.per_share") %>
|
||||
</p>
|
||||
|
||||
<!-- Per-share input -->
|
||||
<div class="pt-2 border-t border-tertiary">
|
||||
<label class="text-xs text-secondary block mb-1"><%= t("holdings.cost_basis_cell.or_per_share_label") %></label>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-secondary text-sm font-medium"><%= currency.symbol %></span>
|
||||
<input type="text" inputmode="decimal"
|
||||
class="form-field__input grow"
|
||||
placeholder="0.00"
|
||||
autocomplete="off"
|
||||
value="<%= number_with_precision(current_per_share, precision: 2) if current_per_share %>"
|
||||
data-action="input->drawer-cost-basis#updateTotal"
|
||||
data-drawer-cost-basis-target="perShare">
|
||||
<span class="text-secondary text-sm"><%= currency.iso_code %></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<button type="button"
|
||||
class="inline-flex items-center gap-1 px-3 py-2 rounded-lg text-sm font-medium text-primary bg-gray-200 hover:bg-gray-300 theme-dark:bg-gray-700 theme-dark:hover:bg-gray-600"
|
||||
data-action="click->drawer-cost-basis#toggle">
|
||||
<%= t("holdings.cost_basis_cell.cancel") %>
|
||||
</button>
|
||||
<%= f.submit t("holdings.cost_basis_cell.save"), class: "inline-flex items-center gap-1 px-3 py-2 rounded-lg text-sm font-medium text-inverse bg-inverse hover:bg-inverse-hover" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<dt class="text-secondary"><%= t(".total_return_label") %></dt>
|
||||
<dd style="color: <%= @holding.trend&.color %>;">
|
||||
<%= @holding.trend ? render("shared/trend_change", trend: @holding.trend) : t(".unknown") %>
|
||||
</dd>
|
||||
<% if @holding.trend %>
|
||||
<dd style="color: <%= @holding.trend.color %>;">
|
||||
<%= render("shared/trend_change", trend: @holding.trend) %>
|
||||
</dd>
|
||||
<% else %>
|
||||
<dd class="text-secondary"><%= t(".unknown") %></dd>
|
||||
<% end %>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
@@ -85,21 +176,39 @@
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if @holding.account.can_delete_holdings? %>
|
||||
<% if @holding.cost_basis_locked? || @holding.account.can_delete_holdings? %>
|
||||
<% dialog.with_section(title: t(".settings"), open: true) do %>
|
||||
<div class="pb-4">
|
||||
<div class="flex items-center justify-between gap-2 p-3">
|
||||
<div class="text-sm space-y-1">
|
||||
<h4 class="text-primary"><%= t(".delete_title") %></h4>
|
||||
<p class="text-secondary"><%= t(".delete_subtitle") %></p>
|
||||
</div>
|
||||
<% if @holding.cost_basis_locked? %>
|
||||
<div class="flex items-center justify-between gap-2 p-3 border-b border-tertiary">
|
||||
<div class="text-sm space-y-1">
|
||||
<h4 class="text-primary"><%= t(".cost_basis_locked_label") %></h4>
|
||||
<p class="text-secondary"><%= t(".cost_basis_locked_description") %></p>
|
||||
</div>
|
||||
|
||||
<%= button_to t(".delete"),
|
||||
holding_path(@holding),
|
||||
method: :delete,
|
||||
class: "rounded-lg px-3 py-2 text-red-500 text-sm font-medium border border-secondary",
|
||||
data: { turbo_confirm: true } %>
|
||||
</div>
|
||||
<%= button_to t(".unlock_cost_basis"),
|
||||
unlock_cost_basis_holding_path(@holding),
|
||||
method: :post,
|
||||
class: "inline-flex items-center gap-1 px-3 py-2 rounded-lg text-sm font-medium text-primary bg-gray-200 hover:bg-gray-300 theme-dark:bg-gray-700 theme-dark:hover:bg-gray-600",
|
||||
form: { data: { turbo: false } },
|
||||
data: { turbo_confirm: { title: t(".unlock_confirm_title"), body: t(".unlock_confirm_body") } } %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if @holding.account.can_delete_holdings? %>
|
||||
<div class="flex items-center justify-between gap-2 p-3">
|
||||
<div class="text-sm space-y-1">
|
||||
<h4 class="text-primary"><%= t(".delete_title") %></h4>
|
||||
<p class="text-secondary"><%= t(".delete_subtitle") %></p>
|
||||
</div>
|
||||
|
||||
<%= button_to t(".delete"),
|
||||
holding_path(@holding),
|
||||
method: :delete,
|
||||
class: "rounded-lg px-3 py-2 text-red-500 text-sm font-medium border border-secondary",
|
||||
data: { turbo_confirm: true } %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
<div data-drag-and-drop-import-target="overlay" class="fixed inset-0 bg-primary/20 backdrop-blur-sm z-50 hidden flex items-center justify-center pointer-events-none">
|
||||
<div class="text-center p-8 bg-container rounded-xl shadow-2xl border-2 border-dashed border-primary animate-in fade-in zoom-in duration-200">
|
||||
<%= icon("upload", size: "xl", class: "text-primary mb-4 mx-auto w-16 h-16") %>
|
||||
<h3 class="text-2xl font-semibold text-primary mb-2"><%= title %></h3>
|
||||
<p class="text-secondary text-base"><%= subtitle %></p>
|
||||
<div data-drag-and-drop-import-target="overlay" class="fixed inset-0 bg-overlay backdrop-blur-sm z-50 hidden flex items-center justify-center pointer-events-none">
|
||||
<div class="bg-container rounded-xl shadow-border-xs p-6 max-w-sm w-full mx-4 animate-in fade-in zoom-in duration-200">
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<div class="w-12 h-12 rounded-full bg-indigo-500/10 flex items-center justify-center mb-4">
|
||||
<%= icon("upload", class: "text-indigo-500 w-6 h-6") %>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-primary mb-1"><%= title %></h3>
|
||||
<p class="text-sm text-secondary"><%= subtitle %></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -19,8 +19,7 @@
|
||||
data-app-layout-user-id-value="<%= Current.user.id %>">
|
||||
<div
|
||||
class="hidden fixed inset-0 bg-surface z-20 h-full w-full pt-[calc(env(safe-area-inset-top)+0.75rem)] pr-3 pb-[calc(env(safe-area-inset-bottom)+0.75rem)] pl-3 overflow-y-auto transition-all duration-300"
|
||||
data-app-layout-target="mobileSidebar"
|
||||
data-print="hide">
|
||||
data-app-layout-target="mobileSidebar">
|
||||
<div class="mb-2">
|
||||
<%= icon("x", as_button: true, data: { action: "app-layout#closeMobileSidebar" }) %>
|
||||
</div>
|
||||
@@ -34,7 +33,7 @@
|
||||
</div>
|
||||
|
||||
<%# MOBILE - Top nav %>
|
||||
<nav class="lg:hidden flex justify-between items-center p-3" data-print="hide">
|
||||
<nav class="lg:hidden flex justify-between items-center p-3">
|
||||
<%= icon("panel-left", as_button: true, data: { action: "app-layout#openMobileSidebar"}) %>
|
||||
|
||||
<%= link_to root_path, class: "block" do %>
|
||||
@@ -45,7 +44,7 @@
|
||||
</nav>
|
||||
|
||||
<%# DESKTOP - Left navbar %>
|
||||
<div class="hidden lg:block" data-print="hide">
|
||||
<div class="hidden lg:block">
|
||||
<nav class="h-full flex flex-col shrink-0 w-[84px] py-4 mr-3">
|
||||
<div class="pl-2 mb-3">
|
||||
<%= link_to root_path, class: "block" do %>
|
||||
@@ -79,7 +78,7 @@
|
||||
"hidden lg:block py-4 overflow-y-auto shrink-0 max-w-[320px] transition-all duration-300",
|
||||
Current.user.show_sidebar? ? expanded_sidebar_class : collapsed_sidebar_class,
|
||||
),
|
||||
data: { app_layout_target: "leftSidebar", print: "hide" } do %>
|
||||
data: { app_layout_target: "leftSidebar" } do %>
|
||||
<% if content_for?(:sidebar) %>
|
||||
<%= yield :sidebar %>
|
||||
<% else %>
|
||||
@@ -139,7 +138,7 @@
|
||||
"hidden lg:block h-full overflow-y-auto shrink-0 max-w-[400px] transition-all duration-300",
|
||||
Current.user.show_ai_sidebar? ? expanded_sidebar_class : collapsed_sidebar_class,
|
||||
),
|
||||
data: { app_layout_target: "rightSidebar", print: "hide" } do %>
|
||||
data: { app_layout_target: "rightSidebar" } do %>
|
||||
<%= tag.div id: "chat-container", class: "relative h-full", data: { controller: "chat hotkey", turbo_permanent: true } do %>
|
||||
<div class="flex flex-col h-full justify-between shrink-0">
|
||||
<%= turbo_frame_tag chat_frame, src: chat_view_path(@chat), loading: "lazy", class: "h-full" do %>
|
||||
@@ -158,7 +157,7 @@
|
||||
<% end %>
|
||||
|
||||
<%# MOBILE - Bottom Nav %>
|
||||
<%= tag.nav class: "lg:hidden fixed bottom-0 left-0 right-0 bg-surface z-10 pb-[env(safe-area-inset-bottom)] border-t border-tertiary flex justify-around", data: { print: "hide" } do %>
|
||||
<%= tag.nav class: "lg:hidden fixed bottom-0 left-0 right-0 bg-surface z-10 pb-[env(safe-area-inset-bottom)] border-t border-tertiary flex justify-around" do %>
|
||||
<% mobile_nav_items.each do |nav_item| %>
|
||||
<%= render "layouts/shared/nav_item", **nav_item %>
|
||||
<% end %>
|
||||
|
||||
28
app/views/layouts/print.html.erb
Normal file
28
app/views/layouts/print.html.erb
Normal file
@@ -0,0 +1,28 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="font-sans">
|
||||
<head>
|
||||
<title><%= content_for(:title) || t("reports.print.document_title") %></title>
|
||||
|
||||
<%= csrf_meta_tags %>
|
||||
<%= stylesheet_link_tag "tailwind", "data-turbo-track": "reload" %>
|
||||
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
</head>
|
||||
|
||||
<body class="bg-white text-gray-900 antialiased print-body">
|
||||
<div class="print-container">
|
||||
<%= yield %>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Auto-trigger print dialog when page loads
|
||||
window.onload = function() {
|
||||
// Small delay to ensure styles are loaded
|
||||
setTimeout(function() {
|
||||
window.print();
|
||||
}, 500);
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -119,7 +119,7 @@
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<% investment_metrics[:accounts].each do |account| %>
|
||||
<div class="bg-container-inset rounded-lg p-4 flex items-center justify-between">
|
||||
<%= link_to account_path(account), class: "bg-container-inset rounded-lg p-4 flex items-center justify-between hover:bg-container-hover transition-colors" do %>
|
||||
<div class="flex items-center gap-3">
|
||||
<%= render "accounts/logo", account: account, size: "sm" %>
|
||||
<div>
|
||||
@@ -128,7 +128,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<p class="font-medium text-primary"><%= format_money(account.balance_money) %></p>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div>
|
||||
<%# Export Controls %>
|
||||
<div class="reports-print-hide flex items-center justify-end mb-4 flex-wrap gap-3">
|
||||
<div class="flex items-center justify-end mb-4 flex-wrap gap-3">
|
||||
<%
|
||||
# Build params hash for links
|
||||
base_params = {
|
||||
|
||||
@@ -79,4 +79,3 @@
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,103 +1,5 @@
|
||||
<% content_for :head do %>
|
||||
<style media="print">
|
||||
/* Print-specific custom property overrides for values using --alpha() */
|
||||
:root {
|
||||
--print-shadow-color: rgba(11, 11, 11, 0.06);
|
||||
}
|
||||
|
||||
@page {
|
||||
margin: 0.75in;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--color-gray-50);
|
||||
color: var(--color-gray-900);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
#notification-tray,
|
||||
#cta,
|
||||
#chat-container,
|
||||
turbo-frame#modal,
|
||||
turbo-frame#drawer,
|
||||
[data-print="hide"] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
[data-controller="app-layout"] {
|
||||
height: auto !important;
|
||||
overflow: visible !important;
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
[data-app-layout-target="content"] {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
height: auto !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
body,
|
||||
html {
|
||||
height: auto !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
main {
|
||||
overflow: visible !important;
|
||||
height: auto !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
|
||||
[data-app-layout-target="content"] > .hidden.lg\:flex {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.reports-page {
|
||||
background: transparent;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.reports-print-sheet {
|
||||
background: var(--color-white);
|
||||
border-radius: 16px;
|
||||
padding: 0.75in 0.65in;
|
||||
box-shadow: 0 10px 25px var(--print-shadow-color);
|
||||
}
|
||||
|
||||
.reports-page section {
|
||||
break-inside: avoid;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
.reports-print-hide,
|
||||
[data-reports-section-target="button"],
|
||||
[data-reports-sortable-target="handle"] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
[data-reports-section-target="content"] {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
section[data-section-key="transactions_breakdown"] {
|
||||
break-before: page;
|
||||
page-break-before: always;
|
||||
margin-top: 0 !important;
|
||||
padding-top: 0 !important;
|
||||
}
|
||||
|
||||
.reports-page table th,
|
||||
.reports-page table td {
|
||||
padding-inline: 0.5rem !important;
|
||||
}
|
||||
</style>
|
||||
<% end %>
|
||||
|
||||
<% content_for :page_header do %>
|
||||
<div class="space-y-4 mb-6 reports-print-hide">
|
||||
<div class="space-y-4 mb-6">
|
||||
<div class="space-y-1">
|
||||
<h1 class="text-xl lg:text-3xl font-medium text-primary">
|
||||
<%= t("reports.index.title") %>
|
||||
@@ -115,42 +17,54 @@
|
||||
<% end %>
|
||||
|
||||
<%# Period Navigation Tabs %>
|
||||
<div class="reports-print-hide flex items-center gap-2 overflow-x-auto pb-2">
|
||||
<%= render DS::Link.new(
|
||||
text: t("reports.index.periods.monthly"),
|
||||
variant: @period_type == :monthly ? "secondary" : "ghost",
|
||||
href: reports_path(period_type: :monthly),
|
||||
size: :sm
|
||||
) %>
|
||||
<%= render DS::Link.new(
|
||||
text: t("reports.index.periods.quarterly"),
|
||||
variant: @period_type == :quarterly ? "secondary" : "ghost",
|
||||
href: reports_path(period_type: :quarterly),
|
||||
size: :sm
|
||||
) %>
|
||||
<%= render DS::Link.new(
|
||||
text: t("reports.index.periods.ytd"),
|
||||
variant: @period_type == :ytd ? "secondary" : "ghost",
|
||||
href: reports_path(period_type: :ytd),
|
||||
size: :sm
|
||||
) %>
|
||||
<%= render DS::Link.new(
|
||||
text: t("reports.index.periods.last_6_months"),
|
||||
variant: @period_type == :last_6_months ? "secondary" : "ghost",
|
||||
href: reports_path(period_type: :last_6_months),
|
||||
size: :sm
|
||||
) %>
|
||||
<%= render DS::Link.new(
|
||||
text: t("reports.index.periods.custom"),
|
||||
variant: @period_type == :custom ? "secondary" : "ghost",
|
||||
href: reports_path(period_type: :custom),
|
||||
size: :sm
|
||||
) %>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-2 overflow-x-auto pb-2">
|
||||
<%= render DS::Link.new(
|
||||
text: t("reports.index.periods.monthly"),
|
||||
variant: @period_type == :monthly ? "secondary" : "ghost",
|
||||
href: reports_path(period_type: :monthly),
|
||||
size: :sm
|
||||
) %>
|
||||
<%= render DS::Link.new(
|
||||
text: t("reports.index.periods.quarterly"),
|
||||
variant: @period_type == :quarterly ? "secondary" : "ghost",
|
||||
href: reports_path(period_type: :quarterly),
|
||||
size: :sm
|
||||
) %>
|
||||
<%= render DS::Link.new(
|
||||
text: t("reports.index.periods.ytd"),
|
||||
variant: @period_type == :ytd ? "secondary" : "ghost",
|
||||
href: reports_path(period_type: :ytd),
|
||||
size: :sm
|
||||
) %>
|
||||
<%= render DS::Link.new(
|
||||
text: t("reports.index.periods.last_6_months"),
|
||||
variant: @period_type == :last_6_months ? "secondary" : "ghost",
|
||||
href: reports_path(period_type: :last_6_months),
|
||||
size: :sm
|
||||
) %>
|
||||
<%= render DS::Link.new(
|
||||
text: t("reports.index.periods.custom"),
|
||||
variant: @period_type == :custom ? "secondary" : "ghost",
|
||||
href: reports_path(period_type: :custom),
|
||||
size: :sm
|
||||
) %>
|
||||
</div>
|
||||
|
||||
<%# Print Report Button %>
|
||||
<%= link_to print_reports_path(period_type: @period_type, start_date: @start_date, end_date: @end_date),
|
||||
target: "_blank",
|
||||
rel: "noopener",
|
||||
aria: { label: t("reports.index.print_report") },
|
||||
class: "inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-secondary bg-surface-inset hover:bg-surface-hover rounded-lg transition-colors flex-shrink-0" do %>
|
||||
<%= icon("printer", size: "sm") %>
|
||||
<span class="hidden sm:inline"><%= t("reports.index.print_report") %></span>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%# Custom Date Range Picker (only shown when custom is selected) %>
|
||||
<% if @period_type == :custom %>
|
||||
<%= form_with url: reports_path, method: :get, data: { controller: "auto-submit-form" }, class: "reports-print-hide flex items-center gap-3 bg-surface-inset p-3 rounded-lg" do |f| %>
|
||||
<%= form_with url: reports_path, method: :get, data: { controller: "auto-submit-form" }, class: "flex items-center gap-3 bg-surface-inset p-3 rounded-lg" do |f| %>
|
||||
<%= f.hidden_field :period_type, value: :custom %>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -182,7 +96,7 @@
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="reports-page w-full space-y-6 pb-24 reports-print-sheet">
|
||||
<div class="w-full space-y-6 pb-24">
|
||||
<% if Current.family.transactions.any? %>
|
||||
<%# Summary Dashboard - Always visible, not collapsible %>
|
||||
<section>
|
||||
|
||||
345
app/views/reports/print.html.erb
Normal file
345
app/views/reports/print.html.erb
Normal file
@@ -0,0 +1,345 @@
|
||||
<% content_for :title do %>
|
||||
<%= t("reports.print.document_title") %> - <%= @start_date.strftime("%B %d, %Y") %> to <%= @end_date.strftime("%B %d, %Y") %>
|
||||
<% end %>
|
||||
|
||||
<div class="tufte-report">
|
||||
<%# Header %>
|
||||
<header class="tufte-header">
|
||||
<h1 class="tufte-title"><%= t("reports.print.title") %></h1>
|
||||
<span class="tufte-period"><%= @start_date.strftime("%B %d, %Y") %> – <%= @end_date.strftime("%B %d, %Y") %></span>
|
||||
<p class="tufte-meta"><%= Current.family.name %> · <%= t("reports.print.generated_on", date: Time.current.strftime("%B %d, %Y")) %></p>
|
||||
</header>
|
||||
|
||||
<%# Summary %>
|
||||
<section class="tufte-section">
|
||||
<h2 class="tufte-section-title"><%= t("reports.print.summary.title") %></h2>
|
||||
<div class="tufte-metric-row">
|
||||
<div class="tufte-metric-card tufte-metric-card-sm">
|
||||
<span class="tufte-metric-card-label"><%= t("reports.print.summary.income") %></span>
|
||||
<span class="tufte-metric-card-value tufte-income"><%= @summary_metrics[:current_income].format %></span>
|
||||
<% if @summary_metrics[:income_change] && @summary_metrics[:income_change] != 0 %>
|
||||
<span class="tufte-metric-card-change <%= @summary_metrics[:income_change] >= 0 ? "tufte-up" : "tufte-down" %>">
|
||||
<%= t("reports.print.summary.vs_prior", percent: (@summary_metrics[:income_change] >= 0 ? "+#{@summary_metrics[:income_change]}" : @summary_metrics[:income_change].to_s)) %>
|
||||
</span>
|
||||
<% end %>
|
||||
<% if has_sparkline_data?(@trends_data) %>
|
||||
<svg width="60" height="20" viewBox="0 0 60 20" style="display:block;margin-top:6px;">
|
||||
<polyline points="<%= sparkline_points(@trends_data.map { |t| t[:income] }, width: 60, height: 20) %>" fill="none" stroke="#047857" stroke-width="1.5" />
|
||||
</svg>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="tufte-metric-card tufte-metric-card-sm">
|
||||
<span class="tufte-metric-card-label"><%= t("reports.print.summary.expenses") %></span>
|
||||
<span class="tufte-metric-card-value tufte-expense"><%= @summary_metrics[:current_expenses].format %></span>
|
||||
<% if @summary_metrics[:expense_change] && @summary_metrics[:expense_change] != 0 %>
|
||||
<span class="tufte-metric-card-change <%= @summary_metrics[:expense_change] >= 0 ? "tufte-down" : "tufte-up" %>">
|
||||
<%= t("reports.print.summary.vs_prior", percent: (@summary_metrics[:expense_change] >= 0 ? "+#{@summary_metrics[:expense_change]}" : @summary_metrics[:expense_change].to_s)) %>
|
||||
</span>
|
||||
<% end %>
|
||||
<% if has_sparkline_data?(@trends_data) %>
|
||||
<svg width="60" height="20" viewBox="0 0 60 20" style="display:block;margin-top:6px;">
|
||||
<polyline points="<%= sparkline_points(@trends_data.map { |t| t[:expenses] }, width: 60, height: 20) %>" fill="none" stroke="#b91c1c" stroke-width="1.5" />
|
||||
</svg>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="tufte-metric-card tufte-metric-card-sm">
|
||||
<span class="tufte-metric-card-label"><%= t("reports.print.summary.net_savings") %></span>
|
||||
<span class="tufte-metric-card-value <%= @summary_metrics[:net_savings] >= 0 ? "tufte-income" : "tufte-expense" %>"><%= @summary_metrics[:net_savings].format %></span>
|
||||
<%
|
||||
# Calculate savings rate
|
||||
savings_rate = @summary_metrics[:current_income].amount > 0 ? ((@summary_metrics[:net_savings].amount / @summary_metrics[:current_income].amount) * 100).round(0) : 0
|
||||
%>
|
||||
<% if savings_rate != 0 %>
|
||||
<span class="tufte-metric-card-change" style="color: #666;"><%= t("reports.print.summary.of_income", percent: savings_rate) %></span>
|
||||
<% end %>
|
||||
<% if has_sparkline_data?(@trends_data) %>
|
||||
<svg width="60" height="20" viewBox="0 0 60 20" style="display:block;margin-top:6px;">
|
||||
<polyline points="<%= sparkline_points(@trends_data.map { |t| t[:net] }, width: 60, height: 20) %>" fill="none" stroke="<%= @summary_metrics[:net_savings] >= 0 ? "#047857" : "#b91c1c" %>" stroke-width="1.5" />
|
||||
</svg>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if @summary_metrics[:budget_percent] %>
|
||||
<div class="tufte-metric-card tufte-metric-card-sm">
|
||||
<span class="tufte-metric-card-label"><%= t("reports.print.summary.budget") %></span>
|
||||
<span class="tufte-metric-card-value"><%= @summary_metrics[:budget_percent] %>%</span>
|
||||
<span class="tufte-metric-card-change" style="color: #666;"><%= t("reports.print.summary.used") %></span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<%# Net Worth %>
|
||||
<% if @has_accounts %>
|
||||
<section class="tufte-section">
|
||||
<h2 class="tufte-section-title"><%= t("reports.print.net_worth.title") %></h2>
|
||||
<div class="tufte-metric-row">
|
||||
<div class="tufte-metric-card">
|
||||
<span class="tufte-metric-card-label"><%= t("reports.print.net_worth.current_balance") %></span>
|
||||
<span class="tufte-metric-card-value <%= @net_worth_metrics[:current_net_worth] >= 0 ? "tufte-income" : "tufte-expense" %>">
|
||||
<%= @net_worth_metrics[:current_net_worth].format %>
|
||||
</span>
|
||||
<% if @net_worth_metrics[:trend] %>
|
||||
<span class="tufte-metric-card-change" style="color: <%= @net_worth_metrics[:trend].color %>">
|
||||
<%= @net_worth_metrics[:trend].value >= 0 ? "+" : "" %><%= @net_worth_metrics[:trend].value.format %> (<%= @net_worth_metrics[:trend].percent_formatted %>) <%= t("reports.print.net_worth.this_period") %>
|
||||
</span>
|
||||
<% end %>
|
||||
<% if has_sparkline_data?(@trends_data) %>
|
||||
<svg width="80" height="24" viewBox="0 0 80 24" style="display:block;margin-top:8px;">
|
||||
<polyline points="<%= sparkline_points(cumulative_net_values(@trends_data), width: 80, height: 24) %>" fill="none" stroke="<%= @net_worth_metrics[:current_net_worth] >= 0 ? "#047857" : "#b91c1c" %>" stroke-width="1.5" />
|
||||
</svg>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tufte-two-col">
|
||||
<div>
|
||||
<h3 class="tufte-subsection"><%= t("reports.print.net_worth.assets") %> <span class="tufte-income"><%= @net_worth_metrics[:total_assets].format %></span></h3>
|
||||
<% if @net_worth_metrics[:asset_groups].any? %>
|
||||
<table class="tufte-table tufte-compact">
|
||||
<tbody>
|
||||
<% @net_worth_metrics[:asset_groups].each do |group| %>
|
||||
<tr>
|
||||
<td><%= group[:name] %></td>
|
||||
<td class="tufte-right"><%= group[:total].format %></td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
<% end %>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="tufte-subsection"><%= t("reports.print.net_worth.liabilities") %> <span class="tufte-expense"><%= @net_worth_metrics[:total_liabilities].format %></span></h3>
|
||||
<% if @net_worth_metrics[:liability_groups].any? %>
|
||||
<table class="tufte-table tufte-compact">
|
||||
<tbody>
|
||||
<% @net_worth_metrics[:liability_groups].each do |group| %>
|
||||
<tr>
|
||||
<td><%= group[:name] %></td>
|
||||
<td class="tufte-right"><%= group[:total].format %></td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
<% else %>
|
||||
<p class="tufte-muted" style="margin: 0;"><%= t("reports.print.net_worth.no_liabilities") %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<% end %>
|
||||
|
||||
<%# Monthly Trends %>
|
||||
<% if has_sparkline_data?(@trends_data) %>
|
||||
<section class="tufte-section">
|
||||
<h2 class="tufte-section-title"><%= t("reports.print.trends.title") %></h2>
|
||||
<table class="tufte-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><%= t("reports.print.trends.month") %></th>
|
||||
<th class="tufte-right"><%= t("reports.print.trends.income") %></th>
|
||||
<th class="tufte-right"><%= t("reports.print.trends.expenses") %></th>
|
||||
<th class="tufte-right"><%= t("reports.print.trends.net") %></th>
|
||||
<th class="tufte-right"><%= t("reports.print.trends.savings_rate") %></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% @trends_data.each do |trend| %>
|
||||
<tr class="<%= trend[:is_current_month] ? "tufte-highlight" : "" %>">
|
||||
<td><%= trend[:month] %><%= trend[:is_current_month] ? " *" : "" %></td>
|
||||
<td class="tufte-right tufte-income"><%= Money.new(trend[:income], Current.family.currency).format %></td>
|
||||
<td class="tufte-right tufte-expense"><%= Money.new(trend[:expenses], Current.family.currency).format %></td>
|
||||
<td class="tufte-right <%= trend[:net] >= 0 ? "tufte-income" : "tufte-expense" %>"><%= Money.new(trend[:net], Current.family.currency).format %></td>
|
||||
<td class="tufte-right">
|
||||
<% month_savings_rate = trend[:income] > 0 ? ((trend[:net].to_f / trend[:income].to_f) * 100).round(0) : 0 %>
|
||||
<%= month_savings_rate %>%
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<%
|
||||
total_income = @trends_data.sum { |t| t[:income].to_d }
|
||||
total_expenses = @trends_data.sum { |t| t[:expenses].to_d }
|
||||
total_net = @trends_data.sum { |t| t[:net].to_d }
|
||||
trends_count = @trends_data.length
|
||||
avg_income = trends_count > 0 ? (total_income / trends_count) : 0
|
||||
avg_expenses = trends_count > 0 ? (total_expenses / trends_count) : 0
|
||||
avg_net = trends_count > 0 ? (total_net / trends_count) : 0
|
||||
overall_savings_rate = total_income > 0 ? ((total_net / total_income) * 100).round(0) : 0
|
||||
%>
|
||||
<tr>
|
||||
<td><%= t("reports.print.trends.average") %></td>
|
||||
<td class="tufte-right tufte-income"><%= Money.new(avg_income, Current.family.currency).format %></td>
|
||||
<td class="tufte-right tufte-expense"><%= Money.new(avg_expenses, Current.family.currency).format %></td>
|
||||
<td class="tufte-right <%= avg_net >= 0 ? "tufte-income" : "tufte-expense" %>"><%= Money.new(avg_net, Current.family.currency).format %></td>
|
||||
<td class="tufte-right"><%= overall_savings_rate %>%</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
<% if @trends_data.any? { |t| t[:is_current_month] } %>
|
||||
<p class="tufte-footnote"><%= t("reports.print.trends.current_month_note") %></p>
|
||||
<% end %>
|
||||
</section>
|
||||
<% end %>
|
||||
|
||||
<%# Investments %>
|
||||
<% if @investment_metrics[:has_investments] %>
|
||||
<section class="tufte-section">
|
||||
<h2 class="tufte-section-title"><%= t("reports.print.investments.title") %></h2>
|
||||
<div class="tufte-metric-row">
|
||||
<div class="tufte-metric-card tufte-metric-card-sm">
|
||||
<span class="tufte-metric-card-label"><%= t("reports.print.investments.portfolio_value") %></span>
|
||||
<span class="tufte-metric-card-value"><%= format_money(@investment_metrics[:portfolio_value]) %></span>
|
||||
<% if has_sparkline_data?(@trends_data) %>
|
||||
<svg width="60" height="20" viewBox="0 0 60 20" style="display:block;margin-top:6px;">
|
||||
<polyline points="<%= sparkline_points(cumulative_net_values(@trends_data), width: 60, height: 20) %>" fill="none" stroke="#6366f1" stroke-width="1.5" />
|
||||
</svg>
|
||||
<% end %>
|
||||
</div>
|
||||
<% if @investment_metrics[:unrealized_trend] %>
|
||||
<div class="tufte-metric-card tufte-metric-card-sm">
|
||||
<span class="tufte-metric-card-label"><%= t("reports.print.investments.total_return") %></span>
|
||||
<span class="tufte-metric-card-value" style="color: <%= @investment_metrics[:unrealized_trend].color %>">
|
||||
<%= @investment_metrics[:unrealized_trend].value >= 0 ? "+" : "" %><%= format_money(Money.new(@investment_metrics[:unrealized_trend].value, Current.family.currency)) %>
|
||||
</span>
|
||||
<span class="tufte-metric-card-change" style="color: <%= @investment_metrics[:unrealized_trend].color %>">
|
||||
<%= @investment_metrics[:unrealized_trend].percent_formatted %>
|
||||
</span>
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="tufte-metric-card tufte-metric-card-sm">
|
||||
<span class="tufte-metric-card-label"><%= t("reports.print.investments.contributions") %></span>
|
||||
<span class="tufte-metric-card-value tufte-income"><%= format_money(@investment_metrics[:period_contributions]) %></span>
|
||||
<span class="tufte-metric-card-change" style="color: #666;"><%= t("reports.print.investments.this_period") %></span>
|
||||
</div>
|
||||
<div class="tufte-metric-card tufte-metric-card-sm">
|
||||
<span class="tufte-metric-card-label"><%= t("reports.print.investments.withdrawals") %></span>
|
||||
<span class="tufte-metric-card-value tufte-expense"><%= format_money(@investment_metrics[:period_withdrawals]) %></span>
|
||||
<span class="tufte-metric-card-change" style="color: #666;"><%= t("reports.print.investments.this_period") %></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% if @investment_metrics[:top_holdings].any? %>
|
||||
<h3 class="tufte-subsection"><%= t("reports.print.investments.top_holdings") %></h3>
|
||||
<table class="tufte-table tufte-compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><%= t("reports.print.investments.holding") %></th>
|
||||
<th class="tufte-right"><%= t("reports.print.investments.weight") %></th>
|
||||
<th class="tufte-right"><%= t("reports.print.investments.value") %></th>
|
||||
<th class="tufte-right"><%= t("reports.print.investments.return") %></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% @investment_metrics[:top_holdings].each do |holding| %>
|
||||
<tr>
|
||||
<td><strong><%= holding.ticker %></strong> <span class="tufte-muted"><%= truncate(holding.name, length: 25) %></span></td>
|
||||
<td class="tufte-right"><%= number_to_percentage(holding.weight || 0, precision: 1) %></td>
|
||||
<td class="tufte-right"><%= format_money(holding.amount_money) %></td>
|
||||
<td class="tufte-right">
|
||||
<% if holding.trend %>
|
||||
<span style="color: <%= holding.trend.color %>"><%= holding.trend.percent_formatted %></span>
|
||||
<% else %>
|
||||
<span class="tufte-muted">—</span>
|
||||
<% end %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
<% end %>
|
||||
</section>
|
||||
<% end %>
|
||||
|
||||
<%# Spending by Category %>
|
||||
<% if @transactions.any? %>
|
||||
<section class="tufte-section tufte-keep-together">
|
||||
<h2 class="tufte-section-title"><%= t("reports.print.spending.title") %></h2>
|
||||
<%
|
||||
income_groups = @transactions.select { |g| g[:type] == "income" }
|
||||
expense_groups = @transactions.select { |g| g[:type] == "expense" }
|
||||
income_total = income_groups.sum { |g| g[:total] }
|
||||
expense_total = expense_groups.sum { |g| g[:total] }
|
||||
%>
|
||||
|
||||
<div class="tufte-two-col">
|
||||
<% if income_groups.any? %>
|
||||
<div>
|
||||
<h3 class="tufte-subsection"><%= t("reports.print.spending.income") %> <span class="tufte-income"><%= Money.new(income_total, Current.family.currency).format %></span></h3>
|
||||
<table class="tufte-table tufte-compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><%= t("reports.print.spending.category") %></th>
|
||||
<th class="tufte-right"><%= t("reports.print.spending.amount") %></th>
|
||||
<th class="tufte-right"><%= t("reports.print.spending.percent") %></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% income_groups.first(8).each do |group| %>
|
||||
<% percentage = income_total.zero? ? 0 : (group[:total].to_f / income_total * 100).round(0) %>
|
||||
<tr>
|
||||
<td>
|
||||
<span class="tufte-dot" style="background: <%= group[:category_color] %>"></span>
|
||||
<%= group[:category_name] %>
|
||||
</td>
|
||||
<td class="tufte-right"><%= Money.new(group[:total], Current.family.currency).format %></td>
|
||||
<td class="tufte-right tufte-muted"><%= percentage %>%</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
<% if income_groups.length > 8 %>
|
||||
<tr>
|
||||
<td class="tufte-muted"><%= t("reports.print.spending.more_categories", count: income_groups.length - 8) %></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if expense_groups.any? %>
|
||||
<div>
|
||||
<h3 class="tufte-subsection"><%= t("reports.print.spending.expenses") %> <span class="tufte-expense"><%= Money.new(expense_total, Current.family.currency).format %></span></h3>
|
||||
<table class="tufte-table tufte-compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><%= t("reports.print.spending.category") %></th>
|
||||
<th class="tufte-right"><%= t("reports.print.spending.amount") %></th>
|
||||
<th class="tufte-right"><%= t("reports.print.spending.percent") %></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% expense_groups.first(8).each do |group| %>
|
||||
<% percentage = expense_total.zero? ? 0 : (group[:total].to_f / expense_total * 100).round(0) %>
|
||||
<tr>
|
||||
<td>
|
||||
<span class="tufte-dot" style="background: <%= group[:category_color] %>"></span>
|
||||
<%= group[:category_name] %>
|
||||
</td>
|
||||
<td class="tufte-right"><%= Money.new(group[:total], Current.family.currency).format %></td>
|
||||
<td class="tufte-right tufte-muted"><%= percentage %>%</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
<% if expense_groups.length > 8 %>
|
||||
<tr>
|
||||
<td class="tufte-muted"><%= t("reports.print.spending.more_categories", count: expense_groups.length - 8) %></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</section>
|
||||
<% end %>
|
||||
|
||||
<footer class="tufte-footer">
|
||||
<%= product_name %> · <%= @start_date.strftime("%B %Y") %> – <%= @end_date.strftime("%B %Y") %>
|
||||
</footer>
|
||||
</div>
|
||||
@@ -30,7 +30,9 @@ nav_sections = [
|
||||
{ label: t(".api_keys_label"), path: settings_api_key_path, icon: "key" },
|
||||
{ label: t(".self_hosting_label"), path: settings_hosting_path, icon: "database", if: self_hosted? },
|
||||
{ label: "Providers", path: settings_providers_path, icon: "plug" },
|
||||
{ label: t(".imports_label"), path: imports_path, icon: "download" }
|
||||
{ label: t(".imports_label"), path: imports_path, icon: "download" },
|
||||
{ label: "SSO Providers", path: admin_sso_providers_path, icon: "key-round", if: Current.user&.super_admin? },
|
||||
{ label: "Users", path: admin_users_path, icon: "users", if: Current.user&.super_admin? }
|
||||
]
|
||||
} : nil
|
||||
),
|
||||
|
||||
64
app/views/settings/hostings/_sync_settings.html.erb
Normal file
64
app/views/settings/hostings/_sync_settings.html.erb
Normal file
@@ -0,0 +1,64 @@
|
||||
<% env_configured = ENV["SIMPLEFIN_INCLUDE_PENDING"].present? || ENV["PLAID_INCLUDE_PENDING"].present? %>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm"><%= t(".include_pending_label") %></p>
|
||||
<p class="text-secondary text-sm"><%= t(".include_pending_description") %></p>
|
||||
</div>
|
||||
|
||||
<%= styled_form_with model: Setting.new,
|
||||
url: settings_hosting_path,
|
||||
method: :patch,
|
||||
data: { controller: "auto-submit-form", auto_submit_form_trigger_event_value: "change" } do |form| %>
|
||||
<%= form.toggle :syncs_include_pending,
|
||||
checked: Setting.syncs_include_pending,
|
||||
disabled: env_configured,
|
||||
data: { auto_submit_form_target: "auto" } %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm"><%= t(".auto_sync_label") %></p>
|
||||
<p class="text-secondary text-sm"><%= t(".auto_sync_description") %></p>
|
||||
</div>
|
||||
|
||||
<%= styled_form_with model: Setting.new,
|
||||
url: settings_hosting_path,
|
||||
method: :patch,
|
||||
data: { controller: "auto-submit-form", auto_submit_form_trigger_event_value: "change" } do |form| %>
|
||||
<%= form.toggle :auto_sync_enabled,
|
||||
checked: Setting.auto_sync_enabled,
|
||||
data: { auto_submit_form_target: "auto" } %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm"><%= t(".auto_sync_time_label") %></p>
|
||||
<p class="text-secondary text-sm"><%= t(".auto_sync_time_description") %></p>
|
||||
</div>
|
||||
|
||||
<%= form_with model: Setting.new,
|
||||
url: settings_hosting_path,
|
||||
method: :patch,
|
||||
data: { controller: "auto-submit-form", auto_submit_form_trigger_event_value: "change" } do |form| %>
|
||||
<%= form.time_field :auto_sync_time,
|
||||
value: Setting.auto_sync_time,
|
||||
disabled: !Setting.auto_sync_enabled,
|
||||
class: "rounded-lg border border-primary px-3 py-2 text-sm bg-container text-primary w-full",
|
||||
data: { auto_submit_form_target: "auto" } %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if env_configured %>
|
||||
<div class="bg-warning-50 border border-warning-200 rounded-lg p-3">
|
||||
<div class="flex items-start gap-2">
|
||||
<%= icon("alert-circle", class: "w-5 h-5 text-warning-600 mt-0.5 shrink-0") %>
|
||||
<p class="text-sm text-warning-800">
|
||||
<%= t(".env_configured_message") %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -16,6 +16,9 @@
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= settings_section title: t(".sync_settings") do %>
|
||||
<%= render "settings/hostings/sync_settings" %>
|
||||
<% end %>
|
||||
<%= settings_section title: t(".invites") do %>
|
||||
<%= render "settings/hostings/invite_code_settings" %>
|
||||
<% end %>
|
||||
|
||||
@@ -44,3 +44,58 @@
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if @oidc_identities.any? || AuthConfig.sso_providers.any? %>
|
||||
<%= settings_section title: t(".sso_title"), subtitle: t(".sso_subtitle") do %>
|
||||
<% if @oidc_identities.any? %>
|
||||
<div class="space-y-2">
|
||||
<% @oidc_identities.each do |identity| %>
|
||||
<div class="flex items-center justify-between bg-container p-4 shadow-border-xs rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-9 h-9 shrink-0 bg-surface rounded-full flex items-center justify-center">
|
||||
<%= icon identity.provider_config&.dig(:icon) || "key", class: "w-5 h-5 text-secondary" %>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-primary"><%= identity.provider_config&.dig(:label) || identity.provider.titleize %></p>
|
||||
<p class="text-sm text-secondary"><%= identity.info&.dig("email") || t(".sso_no_email") %></p>
|
||||
<p class="text-xs text-secondary">
|
||||
<%= t(".sso_last_used") %>:
|
||||
<%= identity.last_authenticated_at&.to_fs(:short) || t(".sso_never") %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<% if @oidc_identities.count > 1 || Current.user.password_digest.present? %>
|
||||
<%= render DS::Button.new(
|
||||
text: t(".sso_disconnect"),
|
||||
variant: "outline",
|
||||
size: "sm",
|
||||
href: settings_sso_identity_path(identity),
|
||||
method: :delete,
|
||||
confirm: CustomConfirm.new(
|
||||
title: t(".sso_confirm_title"),
|
||||
body: t(".sso_confirm_body", provider: identity.provider_config&.dig(:label) || identity.provider.titleize),
|
||||
btn_text: t(".sso_confirm_button"),
|
||||
destructive: true
|
||||
)
|
||||
) %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% if @oidc_identities.count == 1 && Current.user.password_digest.blank? %>
|
||||
<div class="mt-4 p-3 bg-amber-50 border border-amber-200 rounded-lg">
|
||||
<div class="flex items-start gap-2">
|
||||
<%= icon "alert-triangle", class: "w-5 h-5 text-amber-600 shrink-0 mt-0.5" %>
|
||||
<p class="text-sm text-amber-800"><%= t(".sso_warning_message") %></p>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<div class="text-center py-6">
|
||||
<%= icon "link", class: "w-12 h-12 mx-auto text-secondary mb-3" %>
|
||||
<p class="text-secondary"><%= t(".sso_no_identities") %></p>
|
||||
<p class="text-sm text-secondary mt-2"><%= t(".sso_connect_hint") %></p>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
@@ -104,6 +104,25 @@
|
||||
<%= icon "alert-circle", size: "sm", color: "warning" %>
|
||||
<%= tag.span stale_status[:message], class: "text-sm" %>
|
||||
</div>
|
||||
<% elsif (pending_status = simplefin_item.stale_pending_status)[:count] > 0 %>
|
||||
<div class="text-secondary">
|
||||
<div class="flex items-center gap-1">
|
||||
<%= icon "clock", size: "sm", color: "secondary" %>
|
||||
<%= tag.span pending_status[:message], class: "text-sm" %>
|
||||
<span class="text-xs text-tertiary"><%= t(".stale_pending_note") %></span>
|
||||
</div>
|
||||
<% if pending_status[:accounts]&.any? %>
|
||||
<div class="text-xs text-tertiary ml-5">
|
||||
<%= t(".stale_pending_accounts", accounts: pending_status[:accounts].join(", ")) %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% elsif (reconciled_status = simplefin_item.last_sync_reconciled_status)[:count] > 0 %>
|
||||
<div class="text-success flex items-center gap-1">
|
||||
<%= icon "check-circle", size: "sm", color: "success" %>
|
||||
<%= tag.span reconciled_status[:message], class: "text-sm" %>
|
||||
<span class="text-xs text-tertiary"><%= t(".reconciled_details_note") %></span>
|
||||
</div>
|
||||
<% elsif simplefin_item.rate_limited_message.present? %>
|
||||
<div class="text-warning flex items-center gap-1">
|
||||
<%= icon "clock", size: "sm", color: "warning" %>
|
||||
@@ -117,7 +136,7 @@
|
||||
<% elsif duplicate_only_errors %>
|
||||
<div class="text-secondary flex items-center gap-1">
|
||||
<%= icon "info", size: "sm" %>
|
||||
<%= tag.span "Some accounts were skipped as duplicates — use ‘Link existing accounts’ to merge.", class: "text-secondary" %>
|
||||
<%= tag.span t(".duplicate_accounts_skipped"), class: "text-secondary" %>
|
||||
</div>
|
||||
<% else %>
|
||||
<p class="text-secondary">
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="flex md:hidden items-center gap-1 col-span-2 relative">
|
||||
<%= render "transactions/transaction_category", transaction: transaction %>
|
||||
<%= render "transactions/transaction_category", transaction: transaction, variant: "mobile" %>
|
||||
<% if transaction.merchant&.logo_url.present? %>
|
||||
<%= image_tag transaction.merchant.logo_url,
|
||||
class: "w-5 h-5 rounded-full absolute -bottom-1 -right-1 border border-secondary pointer-events-none",
|
||||
@@ -78,14 +78,36 @@
|
||||
</span>
|
||||
<% end %>
|
||||
|
||||
<%# Investment activity label badge %>
|
||||
<% if transaction.investment_activity_label.present? %>
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-alpha-black-50 text-secondary" title="<%= t("transactions.transaction.activity_type_tooltip") %>">
|
||||
<%= t("transactions.activity_labels.#{transaction.investment_activity_label.parameterize(separator: '_')}") %>
|
||||
</span>
|
||||
<% end %>
|
||||
|
||||
<%# Pending indicator %>
|
||||
<% if transaction.pending? %>
|
||||
<span class="inline-flex items-center gap-1 text-xs font-medium rounded-full px-1.5 py-0.5 border border-secondary text-secondary" title="Pending — may change when posted">
|
||||
<span class="inline-flex items-center gap-1 text-xs font-medium rounded-full px-1.5 py-0.5 border border-secondary text-secondary" title="<%= t("transactions.transaction.pending_tooltip") %>">
|
||||
<%= icon "clock", size: "sm", color: "current" %>
|
||||
Pending
|
||||
<%= t("transactions.transaction.pending") %>
|
||||
</span>
|
||||
<% end %>
|
||||
|
||||
<%# Potential duplicate indicator - different styling for low vs medium confidence %>
|
||||
<% if transaction.has_potential_duplicate? %>
|
||||
<% if transaction.low_confidence_duplicate? %>
|
||||
<span class="inline-flex items-center gap-1 text-xs font-medium rounded-full px-1.5 py-0.5 border border-secondary bg-surface-inset text-secondary" title="<%= t("transactions.transaction.review_recommended_tooltip") %>">
|
||||
<%= icon "help-circle", size: "sm", color: "current" %>
|
||||
<%= t("transactions.transaction.review_recommended") %>
|
||||
</span>
|
||||
<% else %>
|
||||
<span class="inline-flex items-center gap-1 text-xs font-medium rounded-full px-1.5 py-0.5 border border-warning bg-warning/10 text-warning" title="<%= t("transactions.transaction.potential_duplicate_tooltip") %>">
|
||||
<%= icon "alert-triangle", size: "sm", color: "current" %>
|
||||
<%= t("transactions.transaction.possible_duplicate") %>
|
||||
</span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<% if transaction.transfer.present? %>
|
||||
<%= render "transactions/transfer_match", transaction: transaction %>
|
||||
<% end %>
|
||||
@@ -122,7 +144,7 @@
|
||||
</div>
|
||||
|
||||
<div class="hidden md:flex items-center gap-1 col-span-2">
|
||||
<%= render "transactions/transaction_category", transaction: transaction %>
|
||||
<%= render "transactions/transaction_category", transaction: transaction, variant: "desktop" %>
|
||||
</div>
|
||||
|
||||
<div class="shrink-0 col-span-4 lg:col-span-2 ml-auto text-right">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<%# locals: (transaction:) %>
|
||||
<%# locals: (transaction:, variant:) %>
|
||||
|
||||
<div id="<%= dom_id(transaction, "category_menu") %>">
|
||||
<div id="<%= dom_id(transaction, "category_menu_#{variant}") %>">
|
||||
<% if transaction.transfer&.categorizable? || transaction.transfer.nil? %>
|
||||
<%= render "categories/menu", transaction: transaction %>
|
||||
<% else %>
|
||||
|
||||
@@ -35,6 +35,11 @@
|
||||
end %>"></div>
|
||||
<p><%= t(".#{param_value.downcase}") %></p>
|
||||
</div>
|
||||
<% elsif param_key == "status" %>
|
||||
<div class="flex items-center gap-2 px-1">
|
||||
<%= icon(param_value.downcase == "pending" ? "clock" : "check", size: "sm") %>
|
||||
<p><%= t(".#{param_value.downcase}") %></p>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="flex items-center gap-2">
|
||||
<p><%= param_value %></p>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<%# locals: (form:) %>
|
||||
|
||||
<div class="p-2 space-y-3">
|
||||
<div class="flex items-center gap-3" data-filter-name="confirmed">
|
||||
<%= form.check_box :status,
|
||||
{
|
||||
multiple: true,
|
||||
checked: @q[:status]&.include?("confirmed"),
|
||||
class: "checkbox checkbox--light"
|
||||
},
|
||||
"confirmed",
|
||||
nil %>
|
||||
<%= form.label :status, t(".confirmed"), value: "confirmed", class: "text-sm text-primary" %>
|
||||
</div>
|
||||
<div class="flex items-center gap-3" data-filter-name="pending">
|
||||
<%= form.check_box :status,
|
||||
{
|
||||
multiple: true,
|
||||
checked: @q[:status]&.include?("pending"),
|
||||
class: "checkbox checkbox--light"
|
||||
},
|
||||
"pending",
|
||||
nil %>
|
||||
<%= form.label :status, t(".pending"), value: "pending", class: "text-sm text-primary" %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -4,6 +4,47 @@
|
||||
<% end %>
|
||||
|
||||
<% dialog.with_body do %>
|
||||
<%# Potential duplicate alert %>
|
||||
<% if @entry.transaction.has_potential_duplicate? %>
|
||||
<% potential_match = @entry.transaction.potential_duplicate_entry %>
|
||||
<% if potential_match %>
|
||||
<div class="mx-4 my-3 p-4 rounded-lg border border-warning bg-warning/5">
|
||||
<div class="flex items-start gap-3">
|
||||
<%= icon "alert-triangle", size: "md", color: "warning" %>
|
||||
<div class="flex-1 space-y-2">
|
||||
<h4 class="text-sm font-medium text-primary"><%= t("transactions.show.potential_duplicate_title") %></h4>
|
||||
<p class="text-sm text-secondary"><%= t("transactions.show.potential_duplicate_description") %></p>
|
||||
|
||||
<div class="mt-3 p-3 rounded bg-container border border-primary">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-primary"><%= potential_match.name %></p>
|
||||
<p class="text-xs text-secondary"><%= potential_match.date.strftime("%b %d, %Y") %> • <%= potential_match.account.name %></p>
|
||||
</div>
|
||||
<p class="text-sm font-medium <%= potential_match.amount.negative? ? "text-green-600" : "text-primary" %>">
|
||||
<%= format_money(-potential_match.amount_money) %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 mt-3">
|
||||
<%= button_to t("transactions.show.merge_duplicate"),
|
||||
merge_duplicate_transaction_path(@entry.transaction),
|
||||
method: :post,
|
||||
class: "btn btn--primary btn--sm",
|
||||
data: { turbo_frame: "_top" } %>
|
||||
<%= button_to t("transactions.show.keep_both"),
|
||||
dismiss_duplicate_transaction_path(@entry.transaction),
|
||||
method: :post,
|
||||
class: "btn btn--outline btn--sm",
|
||||
data: { turbo_frame: "_top" } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<% dialog.with_section(title: t(".overview"), open: true) do %>
|
||||
<div class="pb-4">
|
||||
<%= styled_form_with model: @entry,
|
||||
@@ -153,8 +194,8 @@
|
||||
data: { controller: "auto-submit-form" } do |f| %>
|
||||
<div class="flex cursor-pointer items-center gap-4 justify-between">
|
||||
<div class="text-sm space-y-1">
|
||||
<h4 class="text-primary">Exclude</h4>
|
||||
<p class="text-secondary">Excluded transactions will be removed from budgeting calculations and reports.</p>
|
||||
<h4 class="text-primary"><%= t(".exclude") %></h4>
|
||||
<p class="text-secondary"><%= t(".exclude_description") %></p>
|
||||
</div>
|
||||
|
||||
<%= f.toggle :excluded, { data: { auto_submit_form_target: "auto" } } %>
|
||||
@@ -162,6 +203,33 @@
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if @entry.account.investment? || @entry.account.crypto? %>
|
||||
<div class="pb-4">
|
||||
<%= styled_form_with model: @entry,
|
||||
url: transaction_path(@entry),
|
||||
class: "p-3",
|
||||
data: { controller: "auto-submit-form" } do |f| %>
|
||||
<%= f.fields_for :entryable do |ef| %>
|
||||
<div class="flex cursor-pointer items-center gap-4 justify-between">
|
||||
<div class="text-sm space-y-1">
|
||||
<h4 class="text-primary"><%= t(".activity_type") %></h4>
|
||||
<p class="text-secondary"><%= t(".activity_type_description") %></p>
|
||||
</div>
|
||||
|
||||
<%= ef.select :investment_activity_label,
|
||||
options_for_select(
|
||||
[["—", nil]] + Transaction::ACTIVITY_LABELS.map { |l| [t("transactions.activity_labels.#{l.parameterize(separator: '_')}"), l] },
|
||||
@entry.entryable.investment_activity_label
|
||||
),
|
||||
{ label: false },
|
||||
{ class: "form-field__input border border-secondary rounded-lg px-3 py-1.5 max-w-40 text-sm",
|
||||
data: { auto_submit_form_target: "auto" } } %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="pb-4">
|
||||
<%= styled_form_with model: @entry,
|
||||
url: transaction_path(@entry),
|
||||
@@ -170,8 +238,8 @@
|
||||
<%= f.fields_for :entryable do |ef| %>
|
||||
<div class="flex cursor-pointer items-center gap-4 justify-between">
|
||||
<div class="text-sm space-y-1">
|
||||
<h4 class="text-primary">One-time <%= @entry.amount.negative? ? "Income" : "Expense" %></h4>
|
||||
<p class="text-secondary">One-time transactions will be excluded from certain budgeting calculations and reports to help you see what's really important.</p>
|
||||
<h4 class="text-primary"><%= t(".one_time_title", type: @entry.amount.negative? ? t("transactions.form.income") : t("transactions.form.expense")) %></h4>
|
||||
<p class="text-secondary"><%= t(".one_time_description") %></p>
|
||||
</div>
|
||||
|
||||
<%= ef.toggle :kind, {
|
||||
|
||||
@@ -2,13 +2,21 @@
|
||||
<%= turbo_stream.replace @transfer.inflow_transaction.entry %>
|
||||
<%= turbo_stream.replace @transfer.outflow_transaction.entry %>
|
||||
|
||||
<%= turbo_stream.replace dom_id(@transfer.inflow_transaction, "category_menu"),
|
||||
<%= turbo_stream.replace dom_id(@transfer.inflow_transaction, "category_menu_mobile"),
|
||||
partial: "transactions/transaction_category",
|
||||
locals: { transaction: @transfer.inflow_transaction } %>
|
||||
locals: { transaction: @transfer.inflow_transaction, variant: "mobile" } %>
|
||||
|
||||
<%= turbo_stream.replace dom_id(@transfer.outflow_transaction, "category_menu"),
|
||||
<%= turbo_stream.replace dom_id(@transfer.inflow_transaction, "category_menu_desktop"),
|
||||
partial: "transactions/transaction_category",
|
||||
locals: { transaction: @transfer.outflow_transaction } %>
|
||||
locals: { transaction: @transfer.inflow_transaction, variant: "desktop" } %>
|
||||
|
||||
<%= turbo_stream.replace dom_id(@transfer.outflow_transaction, "category_menu_mobile"),
|
||||
partial: "transactions/transaction_category",
|
||||
locals: { transaction: @transfer.outflow_transaction, variant: "mobile" } %>
|
||||
|
||||
<%= turbo_stream.replace dom_id(@transfer.outflow_transaction, "category_menu_desktop"),
|
||||
partial: "transactions/transaction_category",
|
||||
locals: { transaction: @transfer.outflow_transaction, variant: "desktop" } %>
|
||||
|
||||
<%= turbo_stream.replace dom_id(@transfer.inflow_transaction, "transfer_match"),
|
||||
partial: "transactions/transfer_match",
|
||||
|
||||
Reference in New Issue
Block a user