mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-21 14:19:39 +02:00
Categories, tags, merchants, and menus improvements (#1135)
This commit is contained in:
parent
f82ce59dad
commit
38c2b4670c
22 changed files with 250 additions and 210 deletions
|
@ -94,6 +94,14 @@
|
||||||
.tooltip {
|
.tooltip {
|
||||||
@apply hidden absolute;
|
@apply hidden absolute;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
@apply px-3 py-2 rounded-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--primary {
|
||||||
|
@apply bg-gray-900 text-white hover:bg-gray-700;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Small, single purpose classes that should take precedence over other styles */
|
/* Small, single purpose classes that should take precedence over other styles */
|
||||||
|
@ -110,4 +118,4 @@
|
||||||
.scrollbar::-webkit-scrollbar-thumb:hover {
|
.scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
background: #a6a6a6;
|
background: #a6a6a6;
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,6 +1,6 @@
|
||||||
module MenusHelper
|
module MenusHelper
|
||||||
def contextual_menu(&block)
|
def contextual_menu(&block)
|
||||||
tag.div class: "relative cursor-pointer", data: { controller: "menu" } do
|
tag.div data: { controller: "menu" } do
|
||||||
concat contextual_menu_icon
|
concat contextual_menu_icon
|
||||||
concat contextual_menu_content(&block)
|
concat contextual_menu_content(&block)
|
||||||
end
|
end
|
||||||
|
@ -25,13 +25,14 @@ module MenusHelper
|
||||||
|
|
||||||
private
|
private
|
||||||
def contextual_menu_icon
|
def contextual_menu_icon
|
||||||
tag.button class: "flex hover:bg-gray-100 p-2 rounded", data: { menu_target: "button" } do
|
tag.button class: "flex hover:bg-gray-100 p-2 rounded cursor-pointer", data: { menu_target: "button" } do
|
||||||
lucide_icon "more-horizontal", class: "w-5 h-5 text-gray-500"
|
lucide_icon "more-horizontal", class: "w-5 h-5 text-gray-500"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def contextual_menu_content(&block)
|
def contextual_menu_content(&block)
|
||||||
tag.div class: "absolute z-10 top-10 right-0 border border-alpha-black-25 bg-white rounded-lg shadow-xs hidden", data: { menu_target: "content" } do
|
tag.div class: "z-50 border border-alpha-black-25 bg-white rounded-lg shadow-xs hidden",
|
||||||
|
data: { menu_target: "content" } do
|
||||||
capture(&block)
|
capture(&block)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { Controller } from "@hotwired/stimulus";
|
import { Controller } from "@hotwired/stimulus";
|
||||||
|
|
||||||
// Connects to data-controller="merchant-avatar"
|
// Connects to data-controller="color-avatar"
|
||||||
// Used by the transaction merchant form to show a preview of what the avatar will look like
|
// Used by the transaction merchant form to show a preview of what the avatar will look like
|
||||||
export default class extends Controller {
|
export default class extends Controller {
|
||||||
static targets = [
|
static targets = [
|
|
@ -1,61 +1,57 @@
|
||||||
import { Controller } from "@hotwired/stimulus";
|
import { Controller } from "@hotwired/stimulus";
|
||||||
|
import { computePosition, flip, shift, offset, autoUpdate } from '@floating-ui/dom';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A "menu" can contain arbitrary content including non-clickable items, links, buttons, and forms.
|
* A "menu" can contain arbitrary content including non-clickable items, links, buttons, and forms.
|
||||||
*
|
|
||||||
* - If you need a form-enabled "select" element, use the "listbox" controller instead.
|
|
||||||
*/
|
*/
|
||||||
export default class extends Controller {
|
export default class extends Controller {
|
||||||
static targets = [
|
static targets = ["button", "content"];
|
||||||
"button",
|
|
||||||
"content",
|
|
||||||
"submenu",
|
|
||||||
"submenuButton",
|
|
||||||
"submenuContent",
|
|
||||||
];
|
|
||||||
|
|
||||||
static values = {
|
static values = {
|
||||||
show: { type: Boolean, default: false },
|
show: Boolean,
|
||||||
showSubmenu: { type: Boolean, default: false },
|
placement: { type: String, default: "bottom-end" },
|
||||||
|
offset: { type: Number, default: 5 },
|
||||||
};
|
};
|
||||||
|
|
||||||
initialize() {
|
connect() {
|
||||||
this.show = this.showValue;
|
this.show = this.showValue;
|
||||||
this.showSubmenu = this.showSubmenuValue;
|
this.boundUpdate = this.update.bind(this);
|
||||||
|
this.addEventListeners();
|
||||||
|
this.startAutoUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
connect() {
|
disconnect() {
|
||||||
|
this.removeEventListeners();
|
||||||
|
this.stopAutoUpdate();
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
addEventListeners() {
|
||||||
this.buttonTarget.addEventListener("click", this.toggle);
|
this.buttonTarget.addEventListener("click", this.toggle);
|
||||||
this.element.addEventListener("keydown", this.handleKeydown);
|
this.element.addEventListener("keydown", this.handleKeydown);
|
||||||
document.addEventListener("click", this.handleOutsideClick);
|
document.addEventListener("click", this.handleOutsideClick);
|
||||||
document.addEventListener("turbo:load", this.handleTurboLoad);
|
document.addEventListener("turbo:load", this.handleTurboLoad);
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnect() {
|
removeEventListeners() {
|
||||||
this.element.removeEventListener("keydown", this.handleKeydown);
|
|
||||||
this.buttonTarget.removeEventListener("click", this.toggle);
|
this.buttonTarget.removeEventListener("click", this.toggle);
|
||||||
|
this.element.removeEventListener("keydown", this.handleKeydown);
|
||||||
document.removeEventListener("click", this.handleOutsideClick);
|
document.removeEventListener("click", this.handleOutsideClick);
|
||||||
document.removeEventListener("turbo:load", this.handleTurboLoad);
|
document.removeEventListener("turbo:load", this.handleTurboLoad);
|
||||||
this.close();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If turbo reloads, we maintain the state of the menu
|
|
||||||
handleTurboLoad = () => {
|
handleTurboLoad = () => {
|
||||||
if (!this.show) this.close();
|
if (!this.show) this.close();
|
||||||
};
|
};
|
||||||
|
|
||||||
handleOutsideClick = (event) => {
|
handleOutsideClick = (event) => {
|
||||||
if (this.show && !this.element.contains(event.target)) {
|
if (this.show && !this.element.contains(event.target)) this.close();
|
||||||
this.close();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
handleKeydown = (event) => {
|
handleKeydown = (event) => {
|
||||||
switch (event.key) {
|
if (event.key === "Escape") {
|
||||||
case "Escape":
|
this.close();
|
||||||
this.close();
|
this.buttonTarget.focus();
|
||||||
this.buttonTarget.focus(); // Bring focus back to the button
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -63,6 +59,7 @@ export default class extends Controller {
|
||||||
this.show = !this.show;
|
this.show = !this.show;
|
||||||
this.contentTarget.classList.toggle("hidden", !this.show);
|
this.contentTarget.classList.toggle("hidden", !this.show);
|
||||||
if (this.show) {
|
if (this.show) {
|
||||||
|
this.update();
|
||||||
this.focusFirstElement();
|
this.focusFirstElement();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -73,12 +70,41 @@ export default class extends Controller {
|
||||||
}
|
}
|
||||||
|
|
||||||
focusFirstElement() {
|
focusFirstElement() {
|
||||||
const focusableElements =
|
const focusableElements = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
||||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
const firstFocusableElement = this.contentTarget.querySelectorAll(focusableElements)[0];
|
||||||
const firstFocusableElement =
|
|
||||||
this.contentTarget.querySelectorAll(focusableElements)[0];
|
|
||||||
if (firstFocusableElement) {
|
if (firstFocusableElement) {
|
||||||
firstFocusableElement.focus();
|
firstFocusableElement.focus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
startAutoUpdate() {
|
||||||
|
if (!this._cleanup) {
|
||||||
|
this._cleanup = autoUpdate(this.buttonTarget, this.contentTarget, this.boundUpdate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stopAutoUpdate() {
|
||||||
|
if (this._cleanup) {
|
||||||
|
this._cleanup();
|
||||||
|
this._cleanup = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
update() {
|
||||||
|
computePosition(this.buttonTarget, this.contentTarget, {
|
||||||
|
placement: this.placementValue,
|
||||||
|
middleware: [
|
||||||
|
offset(this.offsetValue),
|
||||||
|
flip(),
|
||||||
|
shift({ padding: 5 })
|
||||||
|
],
|
||||||
|
}).then(({ x, y }) => {
|
||||||
|
Object.assign(this.contentTarget.style, {
|
||||||
|
position: 'fixed',
|
||||||
|
left: `${x}px`,
|
||||||
|
top: `${y}px`,
|
||||||
|
width: 'max-content',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
<%# locals: (category:) %>
|
<%# locals: (category:) %>
|
||||||
<% category ||= null_category %>
|
<% category ||= null_category %>
|
||||||
|
|
||||||
<span class="border text-sm font-medium px-2.5 py-1 rounded-full content-center"
|
<div>
|
||||||
style="
|
<span class="flex items-center gap-1 text-sm font-medium rounded-full px-1.5 py-1 border border-alpha-black-25"
|
||||||
background-color: color-mix(in srgb, <%= category.color %> 5%, white);
|
style="
|
||||||
border-color: color-mix(in srgb, <%= category.color %> 10%, white);
|
background-color: color-mix(in srgb, <%= category.color %> 5%, white);
|
||||||
color: <%= category.color %>;">
|
color: <%= category.color %>;">
|
||||||
<%= category.name %>
|
<%= category.name %>
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
|
|
21
app/views/categories/_category.html.erb
Normal file
21
app/views/categories/_category.html.erb
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
<%# locals: (category:) %>
|
||||||
|
|
||||||
|
<div id="<%= dom_id(category) %>" class="flex justify-between items-center p-4 bg-white">
|
||||||
|
<div class="flex w-full items-center gap-2.5">
|
||||||
|
<%= render partial: "categories/badge", locals: { category: category } %>
|
||||||
|
</div>
|
||||||
|
<div class="justify-self-end">
|
||||||
|
<%= 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">
|
||||||
|
<%= contextual_menu_modal_action_item t(".edit"), edit_category_path(category) %>
|
||||||
|
|
||||||
|
<%= link_to new_category_deletion_path(category),
|
||||||
|
class: "flex items-center w-full rounded-lg text-red-600 hover:bg-red-50 py-2 px-3 gap-2",
|
||||||
|
data: { turbo_frame: :modal } do %>
|
||||||
|
<%= lucide_icon "trash-2", class: "shrink-0 w-5 h-5" %>
|
||||||
|
<span class="text-sm"><%= t(".delete") %></span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -1,38 +1,25 @@
|
||||||
<%= styled_form_with model: category, data: { turbo: false } do |form| %>
|
<div data-controller="color-avatar">
|
||||||
<div class="flex flex-col space-y-4 w-96" data-controller="color-select" data-color-select-selection-value="<%= category.color %>">
|
<%= styled_form_with model: category, class: "space-y-4", data: { turbo: false } do |f| %>
|
||||||
<fieldset class="relative">
|
<section class="space-y-4">
|
||||||
<span data-color-select-target="decoration" class="pointer-events-none absolute inset-y-3.5 left-3 flex items-center pl-1 block w-1 rounded-lg"></span>
|
<div class="w-fit m-auto">
|
||||||
<%= form.text_field :name,
|
<%= render partial: "shared/color_avatar", locals: { name: category.name, color: category.color } %>
|
||||||
value: category.name,
|
</div>
|
||||||
autofocus: "",
|
<div class="flex gap-2 items-center justify-center">
|
||||||
required: true,
|
|
||||||
placeholder: "Enter Category name",
|
|
||||||
class: "rounded-lg w-full focus:ring-black focus:border-transparent placeholder:text-gray-500 pl-6" %>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<fieldset>
|
|
||||||
<%= form.hidden_field :color, data: { color_select_target: "input" } %>
|
|
||||||
|
|
||||||
<ul role="radiogroup" class="flex justify-between items-center py-2">
|
|
||||||
<% Category::COLORS.each do |color| %>
|
<% Category::COLORS.each do |color| %>
|
||||||
<li tabindex="0"
|
<label class="relative">
|
||||||
role="radio"
|
<%= f.radio_button :color, color, class: "sr-only peer", data: { action: "change->color-avatar#handleColorChange" } %>
|
||||||
data-action="click->color-select#select keydown.enter->color-select#select keydown.space->color-select#select"
|
<div class="w-6 h-6 rounded-full cursor-pointer peer-checked:ring-2 peer-checked:ring-offset-2 peer-checked:ring-blue-500" style="background-color: <%= color %>"></div>
|
||||||
data-value="<%= color %>"
|
</label>
|
||||||
class="flex shrink-0 justify-center items-center w-5 h-5 cursor-pointer hover:bg-gray-200 rounded-full">
|
|
||||||
</li>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
</ul>
|
</div>
|
||||||
</fieldset>
|
<div class="relative flex items-center border border-gray-200 rounded-lg">
|
||||||
|
<%= f.text_field :name, placeholder: t(".placeholder"), class: "text-sm font-normal placeholder:text-gray-500 h-10 relative pl-3 w-full border-none rounded-lg", required: true, data: { color_avatar_target: "name" } %>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<%= hidden_field_tag :transaction_id, params[:transaction_id] %>
|
<%= hidden_field_tag :transaction_id, params[:transaction_id] %>
|
||||||
|
<%= f.submit %>
|
||||||
<% if category.persisted? %>
|
|
||||||
<%= form.submit t(".update") %>
|
|
||||||
<% else %>
|
|
||||||
<%= form.submit t(".create") %>
|
|
||||||
<% end %>
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
<% end %>
|
||||||
<% end %>
|
</div>
|
||||||
|
|
|
@ -1,23 +0,0 @@
|
||||||
<div class="flex justify-between mx-4 py-5 border-b last:border-b-0 border-alpha-black-50">
|
|
||||||
<%= render partial: "categories/badge", locals: { category: row } %>
|
|
||||||
|
|
||||||
<%= 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_category_path(row),
|
|
||||||
class: "block w-full py-2 px-3 space-x-2 text-gray-900 hover:bg-gray-50 flex items-center rounded-lg",
|
|
||||||
data: { turbo_frame: :modal } do %>
|
|
||||||
<%= lucide_icon "pencil-line", class: "w-5 h-5 text-gray-500" %>
|
|
||||||
|
|
||||||
<span><%= t(".edit") %></span>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<%= link_to new_category_deletion_path(row),
|
|
||||||
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_frame: :modal } do %>
|
|
||||||
<%= lucide_icon "trash-2", class: "w-5 h-5" %>
|
|
||||||
|
|
||||||
<span><%= t(".delete") %></span>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
3
app/views/categories/_ruler.html.erb
Normal file
3
app/views/categories/_ruler.html.erb
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
<div class="bg-white">
|
||||||
|
<div class="h-px bg-alpha-black-50 ml-4 mr-6"></div>
|
||||||
|
</div>
|
|
@ -1,24 +1,43 @@
|
||||||
<% content_for :sidebar do %>
|
<% content_for :sidebar do %>
|
||||||
<%= render "settings/nav" %>
|
<%= render "settings/nav" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<section class="space-y-4">
|
<section class="space-y-4">
|
||||||
<header class="flex items-center justify-between">
|
<header class="flex items-center justify-between">
|
||||||
<h1 class="text-gray-900 text-xl font-medium"><%= t(".categories") %></h1>
|
<h1 class="text-gray-900 text-xl font-medium"><%= t(".categories") %></h1>
|
||||||
|
|
||||||
<%= link_to new_category_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 %>
|
<%= link_to new_category_path, class: "btn btn--primary flex items-center gap-1 justify-center", data: { turbo_frame: :modal } do %>
|
||||||
<%= lucide_icon "plus", class: "w-5 h-5" %>
|
<%= lucide_icon "plus", class: "w-5 h-5" %>
|
||||||
<p><%= t(".new") %></p>
|
<p><%= t(".new") %></p>
|
||||||
<% end %>
|
<% end %>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="bg-white shadow-xs border border-alpha-black-25 rounded-xl p-4">
|
<div class="bg-white shadow-xs border border-alpha-black-25 rounded-xl p-4">
|
||||||
<div class="rounded-xl bg-gray-25 p-1">
|
<% if @categories.any? %>
|
||||||
<h2 class="uppercase px-4 py-2 text-gray-500 text-xs"><%= t(".categories") %> · <%= @categories.size %></h2>
|
<div class="rounded-xl bg-gray-25 space-y-1 p-1">
|
||||||
|
<div class="flex items-center gap-1.5 px-4 py-2 text-xs font-medium text-gray-500 uppercase">
|
||||||
|
<p><%= t(".categories") %></p>
|
||||||
|
<span class="text-gray-400">·</span>
|
||||||
|
<p><%= @categories.count %></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="border border-alpha-gray-100 rounded-lg bg-white shadow-xs">
|
<div class="border border-alpha-black-25 rounded-md bg-white shadow-xs">
|
||||||
<%= render collection: @categories, partial: "categories/row" %>
|
<div class="overflow-hidden rounded-md">
|
||||||
|
<%= render partial: @categories, spacer_template: "categories/ruler" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<% else %>
|
||||||
|
<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(".empty") %></p>
|
||||||
|
<%= link_to new_category_path, 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>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="flex justify-between gap-4">
|
<footer class="flex justify-between gap-4">
|
||||||
|
|
|
@ -1,7 +0,0 @@
|
||||||
<%# locals: (merchant:) %>
|
|
||||||
<% name = merchant.name || "?" %>
|
|
||||||
<% background_color = "color-mix(in srgb, #{merchant.color} 5%, white)" %>
|
|
||||||
<% border_color = "color-mix(in srgb, #{merchant.color} 10%, white)" %>
|
|
||||||
<span data-merchant-avatar-target="avatar" class="w-8 h-8 flex items-center justify-center rounded-full" style="background-color: <%= background_color %>; border-color: <%= border_color %>; color: <%= merchant.color %>">
|
|
||||||
<%= name[0].upcase %>
|
|
||||||
</span>
|
|
|
@ -1,19 +1,19 @@
|
||||||
<div data-controller="merchant-avatar">
|
<div data-controller="color-avatar">
|
||||||
<%= styled_form_with model: @merchant, class: "space-y-4", data: { turbo: false } do |f| %>
|
<%= styled_form_with model: @merchant, class: "space-y-4", data: { turbo: false } do |f| %>
|
||||||
<section class="space-y-4">
|
<section class="space-y-4">
|
||||||
<div class="w-fit m-auto">
|
<div class="w-fit m-auto">
|
||||||
<%= render partial: "merchants/avatar", locals: { merchant: @merchant } %>
|
<%= render partial: "shared/color_avatar", locals: { name: @merchant.name, color: @merchant.color } %>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2 items-center justify-center">
|
<div class="flex gap-2 items-center justify-center">
|
||||||
<% Merchant::COLORS.each do |color| %>
|
<% Merchant::COLORS.each do |color| %>
|
||||||
<label class="relative">
|
<label class="relative">
|
||||||
<%= f.radio_button :color, color, class: "sr-only peer", data: { action: "change->merchant-avatar#handleColorChange" } %>
|
<%= f.radio_button :color, color, class: "sr-only peer", data: { action: "change->color-avatar#handleColorChange" } %>
|
||||||
<div class="w-6 h-6 rounded-full cursor-pointer peer-checked:ring-2 peer-checked:ring-offset-2 peer-checked:ring-blue-500" style="background-color: <%= color %>"></div>
|
<div class="w-6 h-6 rounded-full cursor-pointer peer-checked:ring-2 peer-checked:ring-offset-2 peer-checked:ring-blue-500" style="background-color: <%= color %>"></div>
|
||||||
</label>
|
</label>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<div class="relative flex items-center border border-gray-200 rounded-lg">
|
<div class="relative flex items-center border border-gray-200 rounded-lg">
|
||||||
<%= f.text_field :name, placeholder: t(".name_placeholder"), class: "text-sm font-normal placeholder:text-gray-500 h-10 relative pl-3 w-full border-none rounded-lg", required: true, data: { merchant_avatar_target: "name" } %>
|
<%= f.text_field :name, placeholder: t(".name_placeholder"), class: "text-sm font-normal placeholder:text-gray-500 h-10 relative pl-3 w-full border-none rounded-lg", required: true, data: { color_avatar_target: "name" } %>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
<div class="flex justify-between items-center p-4 bg-white">
|
<div class="flex justify-between items-center p-4 bg-white">
|
||||||
<div class="flex w-full items-center gap-2.5">
|
<div class="flex w-full items-center gap-2.5">
|
||||||
<%= render partial: "merchants/avatar", locals: { merchant: } %>
|
<%= render partial: "shared/color_avatar", locals: { name: merchant.name, color: merchant.color } %>
|
||||||
<p class="text-gray-900 text-sm truncate">
|
<p class="text-gray-900 text-sm truncate">
|
||||||
<%= merchant.name %>
|
<%= merchant.name %>
|
||||||
</p>
|
</p>
|
||||||
|
|
|
@ -2,17 +2,32 @@
|
||||||
<%= render "settings/nav" %>
|
<%= render "settings/nav" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<section class="space-y-4">
|
||||||
<div class="flex items-center justify-between">
|
<header class="flex items-center justify-between">
|
||||||
<h1 class="text-gray-900 text-xl font-medium"><%= t(".title") %></h1>
|
<h1 class="text-gray-900 text-xl font-medium"><%= t(".title") %></h1>
|
||||||
|
|
||||||
<%= link_to new_merchant_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 %>
|
<%= link_to new_merchant_path, class: "btn btn--primary flex items-center gap-1 justify-center", data: { turbo_frame: :modal } do %>
|
||||||
<%= lucide_icon("plus", class: "w-5 h-5") %>
|
<%= lucide_icon "plus", class: "w-5 h-5" %>
|
||||||
<span><%= t(".new") %></span>
|
<p><%= t(".new") %></p>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</header>
|
||||||
|
|
||||||
<div class="bg-white shadow-xs border border-alpha-black-25 rounded-xl p-4">
|
<div class="bg-white shadow-xs border border-alpha-black-25 rounded-xl p-4">
|
||||||
<% if @merchants.empty? %>
|
<% if @merchants.any? %>
|
||||||
|
<div class="rounded-xl bg-gray-25 space-y-1 p-1">
|
||||||
|
<div class="flex items-center gap-1.5 px-4 py-2 text-xs font-medium text-gray-500 uppercase">
|
||||||
|
<p><%= t(".title") %></p>
|
||||||
|
<span class="text-gray-400">·</span>
|
||||||
|
<p><%= @merchants.count %></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border border-alpha-black-25 rounded-md bg-white shadow-xs">
|
||||||
|
<div class="overflow-hidden rounded-md">
|
||||||
|
<%= render partial: @merchants, spacer_template: "merchants/ruler" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
<div class="flex justify-center items-center py-20">
|
<div class="flex justify-center items-center py-20">
|
||||||
<div class="text-center flex flex-col items-center max-w-[300px]">
|
<div class="text-center flex flex-col items-center max-w-[300px]">
|
||||||
<p class="text-gray-900 mb-1 font-medium text-sm"><%= t(".empty") %></p>
|
<p class="text-gray-900 mb-1 font-medium text-sm"><%= t(".empty") %></p>
|
||||||
|
@ -22,21 +37,11 @@
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% else %>
|
|
||||||
<div class="bg-gray-25 p-1 rounded-xl">
|
|
||||||
<div class="flex items-center gap-1.5 px-4 py-2 text-xs font-medium text-gray-500">
|
|
||||||
<p><%= t(".title") %></p>
|
|
||||||
<span class="text-gray-400">·</span>
|
|
||||||
<p><%= @merchants.count %></p>
|
|
||||||
</div>
|
|
||||||
<div class="overflow-hidden rounded-lg">
|
|
||||||
<%= render partial: @merchants, spacer_template: "merchants/ruler" %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between gap-4">
|
|
||||||
|
<footer class="flex justify-between gap-4">
|
||||||
<%= previous_setting("Categories", categories_path) %>
|
<%= previous_setting("Categories", categories_path) %>
|
||||||
<%= next_setting("Rules", rules_transactions_path) %>
|
<%= next_setting("Rules", rules_transactions_path) %>
|
||||||
</div>
|
</footer>
|
||||||
</div>
|
</section>
|
||||||
|
|
|
@ -50,7 +50,7 @@
|
||||||
<%= sidebar_link_to t(".tags_label"), tags_path, icon: "tags" %>
|
<%= sidebar_link_to t(".tags_label"), tags_path, icon: "tags" %>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<%= sidebar_link_to t(".categories_label"), categories_path, icon: "tags" %>
|
<%= sidebar_link_to t(".categories_label"), categories_path, icon: "shapes" %>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<%= sidebar_link_to t(".merchants_label"), merchants_path, icon: "store" %>
|
<%= sidebar_link_to t(".merchants_label"), merchants_path, icon: "store" %>
|
||||||
|
|
11
app/views/shared/_color_avatar.html.erb
Normal file
11
app/views/shared/_color_avatar.html.erb
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
<%# locals: (name: nil, color: "#000") %>
|
||||||
|
|
||||||
|
<% letter = name&.first || "?" %>
|
||||||
|
|
||||||
|
<% background_color = "color-mix(in srgb, #{color} 5%, white)" %>
|
||||||
|
<% border_color = "color-mix(in srgb, #{color} 10%, white)" %>
|
||||||
|
<span data-color-avatar-target="avatar"
|
||||||
|
class="w-8 h-8 flex items-center justify-center rounded-full"
|
||||||
|
style="background-color: <%= background_color %>; border-color: <%= border_color %>; color: <%= color %>">
|
||||||
|
<%= letter.upcase %>
|
||||||
|
</span>
|
|
@ -1,38 +1,25 @@
|
||||||
<%= styled_form_with model: tag, data: { turbo: false } do |form| %>
|
<div data-controller="color-avatar">
|
||||||
<div class="flex flex-col space-y-4 w-96" data-controller="color-select" data-color-select-selection-value="<%= tag.color %>">
|
<%= styled_form_with model: tag, class: "space-y-4", data: { turbo: false } do |f| %>
|
||||||
<fieldset class="relative">
|
<section class="space-y-4">
|
||||||
<span data-color-select-target="decoration" class="pointer-events-none absolute inset-y-3.5 left-3 flex items-center pl-1 block w-1 rounded-lg"></span>
|
<div class="w-fit m-auto">
|
||||||
<%= form.text_field :name,
|
<%= render partial: "shared/color_avatar", locals: { name: tag.name, color: tag.color } %>
|
||||||
value: tag.name,
|
</div>
|
||||||
autofocus: "",
|
<div class="flex gap-2 items-center justify-center">
|
||||||
required: true,
|
|
||||||
placeholder: "Enter tag name",
|
|
||||||
class: "rounded-lg w-full focus:ring-black focus:border-transparent placeholder:text-gray-500 pl-6" %>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<fieldset>
|
|
||||||
<%= form.hidden_field :color, data: { color_select_target: "input" } %>
|
|
||||||
|
|
||||||
<ul role="radiogroup" class="flex justify-between items-center py-2">
|
|
||||||
<% Tag::COLORS.each do |color| %>
|
<% Tag::COLORS.each do |color| %>
|
||||||
<li tabindex="0"
|
<label class="relative">
|
||||||
role="radio"
|
<%= f.radio_button :color, color, class: "sr-only peer", data: { action: "change->color-avatar#handleColorChange" } %>
|
||||||
data-action="click->color-select#select keydown.enter->color-select#select keydown.space->color-select#select"
|
<div class="w-6 h-6 rounded-full cursor-pointer peer-checked:ring-2 peer-checked:ring-offset-2 peer-checked:ring-blue-500" style="background-color: <%= color %>"></div>
|
||||||
data-value="<%= color %>"
|
</label>
|
||||||
class="flex shrink-0 justify-center items-center w-5 h-5 cursor-pointer hover:bg-gray-200 rounded-full">
|
|
||||||
</li>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
</ul>
|
</div>
|
||||||
</fieldset>
|
<div class="relative flex items-center border border-gray-200 rounded-lg">
|
||||||
|
<%= f.text_field :name, placeholder: t(".placeholder"), class: "text-sm font-normal placeholder:text-gray-500 h-10 relative pl-3 w-full border-none rounded-lg", required: true, data: { color_avatar_target: "name" } %>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<%= hidden_field_tag :tag_id, params[:tag_id] %>
|
<%= hidden_field_tag :tag_id, params[:tag_id] %>
|
||||||
|
<%= f.submit %>
|
||||||
<% if tag.persisted? %>
|
|
||||||
<%= form.submit t(".update") %>
|
|
||||||
<% else %>
|
|
||||||
<%= form.submit t(".create") %>
|
|
||||||
<% end %>
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
<% end %>
|
||||||
<% end %>
|
</div>
|
||||||
|
|
3
app/views/tags/_ruler.html.erb
Normal file
3
app/views/tags/_ruler.html.erb
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
<div class="bg-white">
|
||||||
|
<div class="h-px bg-alpha-black-50 ml-4 mr-6"></div>
|
||||||
|
</div>
|
|
@ -1,23 +1,24 @@
|
||||||
<div id="<%= dom_id(tag) %>" class="flex justify-between mx-4 py-5 border-b last:border-b-0 border-alpha-black-50">
|
<%# locals: (tag:) %>
|
||||||
<%= render "badge", tag: tag %>
|
|
||||||
|
|
||||||
<%= contextual_menu do %>
|
<div id="<%= dom_id(tag) %>" class="flex justify-between items-center p-4 bg-white">
|
||||||
<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">
|
<div class="flex w-full items-center gap-2.5">
|
||||||
<%= link_to edit_tag_path(tag),
|
<%= render partial: "shared/color_avatar", locals: { name: tag.name, color: tag.color } %>
|
||||||
class: "block w-full py-2 px-3 space-x-2 text-gray-900 hover:bg-gray-50 flex items-center rounded-lg",
|
<p class="text-gray-900 text-sm truncate">
|
||||||
data: { turbo_frame: :modal } do %>
|
<%= tag.name %>
|
||||||
<%= lucide_icon "pencil-line", class: "w-5 h-5 text-gray-500" %>
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="justify-self-end">
|
||||||
|
<%= 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">
|
||||||
|
<%= contextual_menu_modal_action_item t(".edit"), edit_tag_path(tag) %>
|
||||||
|
|
||||||
<span><%= t(".edit") %></span>
|
<%= link_to new_tag_deletion_path(tag),
|
||||||
<% end %>
|
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_frame: :modal } do %>
|
||||||
<%= link_to new_tag_deletion_path(tag),
|
<%= lucide_icon "trash-2", class: "w-5 h-5" %>
|
||||||
class: "block w-full py-2 px-3 space-x-2 text-red-600 hover:bg-red-50 flex items-center rounded-lg",
|
<span><%= t(".delete") %></span>
|
||||||
data: { turbo_frame: :modal } do %>
|
<% end %>
|
||||||
<%= lucide_icon "trash-2", class: "w-5 h-5" %>
|
</div>
|
||||||
|
<% end %>
|
||||||
<span><%= t(".delete") %></span>
|
</div>
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -6,28 +6,28 @@
|
||||||
<header class="flex items-center justify-between">
|
<header class="flex items-center justify-between">
|
||||||
<h1 class="text-gray-900 text-xl font-medium"><%= t(".tags") %></h1>
|
<h1 class="text-gray-900 text-xl font-medium"><%= t(".tags") %></h1>
|
||||||
|
|
||||||
<%= link_to new_tag_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 %>
|
<%= link_to new_tag_path, class: "btn btn--primary flex items-center gap-1 justify-center", data: { turbo_frame: :modal } do %>
|
||||||
<%= lucide_icon "plus", class: "w-5 h-5" %>
|
<%= lucide_icon "plus", class: "w-5 h-5" %>
|
||||||
<p><%= t(".new") %></p>
|
<p><%= t(".new") %></p>
|
||||||
<% end %>
|
<% end %>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="bg-white shadow-xs border border-alpha-black-25 rounded-xl p-4">
|
<div class="bg-white shadow-xs border border-alpha-black-25 rounded-xl p-4">
|
||||||
|
|
||||||
<% if @tags.any? %>
|
<% if @tags.any? %>
|
||||||
|
<div class="rounded-xl bg-gray-25 space-y-1 p-1">
|
||||||
|
<div class="flex items-center gap-1.5 px-4 py-2 text-xs font-medium text-gray-500 uppercase">
|
||||||
|
<p><%= t(".tags") %></p>
|
||||||
|
<span class="text-gray-400">·</span>
|
||||||
|
<p><%= @tags.count %></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="rounded-xl bg-gray-25 p-1">
|
<div class="border border-alpha-black-25 rounded-md bg-white shadow-xs">
|
||||||
<h2 class="uppercase px-4 py-2 text-gray-500 text-xs"><%= t(".tags") %> · <%= @tags.size %></h2>
|
<div class="overflow-hidden rounded-md">
|
||||||
|
<%= render partial: @tags, spacer_template: "tags/ruler" %>
|
||||||
<div class="border border-alpha-gray-100 rounded-lg bg-white shadow-xs">
|
</div>
|
||||||
|
|
||||||
<%= render @tags %>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<% else %>
|
<% else %>
|
||||||
|
|
||||||
<div class="flex justify-center items-center py-20">
|
<div class="flex justify-center items-center py-20">
|
||||||
<div class="text-center flex flex-col items-center max-w-[300px]">
|
<div class="text-center flex flex-col items-center max-w-[300px]">
|
||||||
<p class="text-gray-900 mb-1 font-medium text-sm"><%= t(".empty") %></p>
|
<p class="text-gray-900 mb-1 font-medium text-sm"><%= t(".empty") %></p>
|
||||||
|
@ -37,9 +37,7 @@
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="flex justify-between gap-4">
|
<footer class="flex justify-between gap-4">
|
||||||
|
|
|
@ -1,22 +1,22 @@
|
||||||
---
|
---
|
||||||
en:
|
en:
|
||||||
categories:
|
categories:
|
||||||
|
category:
|
||||||
|
delete: Delete category
|
||||||
|
edit: Edit category
|
||||||
create:
|
create:
|
||||||
success: New transaction category created successfully
|
success: New transaction category created successfully
|
||||||
edit:
|
edit:
|
||||||
edit: Edit category
|
edit: Edit category
|
||||||
form:
|
form:
|
||||||
create: Create category
|
placeholder: Category name
|
||||||
update: Update
|
|
||||||
index:
|
index:
|
||||||
categories: Categories
|
categories: Categories
|
||||||
new: New
|
empty: No categories found
|
||||||
|
new: New category
|
||||||
menu:
|
menu:
|
||||||
loading: Loading...
|
loading: Loading...
|
||||||
new:
|
new:
|
||||||
new_category: New category
|
new_category: New category
|
||||||
row:
|
|
||||||
delete: Delete category
|
|
||||||
edit: Edit category
|
|
||||||
update:
|
update:
|
||||||
success: Transaction category updated successfully
|
success: Transaction category updated successfully
|
||||||
|
|
|
@ -6,8 +6,7 @@ en:
|
||||||
edit:
|
edit:
|
||||||
edit: Edit tag
|
edit: Edit tag
|
||||||
form:
|
form:
|
||||||
create: Create tag
|
placeholder: Tag name
|
||||||
update: Update
|
|
||||||
index:
|
index:
|
||||||
empty: No tags yet
|
empty: No tags yet
|
||||||
new: New tag
|
new: New tag
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue