Merge branch 'feat/friendlier-tag-creation'
This commit is contained in:
commit
dd3f607e13
9 changed files with 671 additions and 106 deletions
377
assets/js/tagcombobox.js
Normal file
377
assets/js/tagcombobox.js
Normal file
|
|
@ -0,0 +1,377 @@
|
|||
(function () {
|
||||
"use strict";
|
||||
|
||||
// --- Chip creation ---
|
||||
function createChip(name, disabled) {
|
||||
var chip = document.createElement("div");
|
||||
chip.setAttribute("data-tagcombobox-chip", name);
|
||||
chip.className =
|
||||
"inline-flex items-center gap-2 rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors border-transparent bg-primary text-primary-foreground";
|
||||
chip.innerHTML =
|
||||
'<span>' + escapeHTML(name) + '</span>' +
|
||||
'<button type="button" class="ml-1 text-current hover:text-destructive disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer" data-tagcombobox-remove="' + escapeAttr(name) + '"' + (disabled ? " disabled" : "") + '>' +
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>' +
|
||||
'</button>';
|
||||
return chip;
|
||||
}
|
||||
|
||||
function escapeHTML(str) {
|
||||
var div = document.createElement("div");
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function escapeAttr(str) {
|
||||
return str.replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// --- State helpers ---
|
||||
function getSelectedTags(container) {
|
||||
var inputs = container.querySelectorAll('[data-tagcombobox-hidden-inputs] input[type="hidden"]');
|
||||
var tags = [];
|
||||
for (var i = 0; i < inputs.length; i++) {
|
||||
tags.push(inputs[i].value.toLowerCase());
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
function isTagSelected(container, name) {
|
||||
return getSelectedTags(container).indexOf(name.toLowerCase()) !== -1;
|
||||
}
|
||||
|
||||
function addTag(container, name) {
|
||||
if (isTagSelected(container, name)) return;
|
||||
|
||||
var disabled = container.querySelector("[data-tagcombobox-text-input]")?.hasAttribute("disabled");
|
||||
var chipsContainer = container.querySelector("[data-tagcombobox-chips]");
|
||||
var hiddenInputs = container.querySelector("[data-tagcombobox-hidden-inputs]");
|
||||
var fieldName = container.getAttribute("data-tagcombobox-name");
|
||||
var formAttr = container.getAttribute("data-tagcombobox-form");
|
||||
|
||||
// Add chip
|
||||
chipsContainer.appendChild(createChip(name, disabled));
|
||||
|
||||
// Add hidden input
|
||||
var input = document.createElement("input");
|
||||
input.type = "hidden";
|
||||
input.name = fieldName;
|
||||
input.value = name;
|
||||
if (formAttr) input.setAttribute("form", formAttr);
|
||||
hiddenInputs.appendChild(input);
|
||||
|
||||
// Update dropdown checkmark
|
||||
updateDropdownState(container);
|
||||
}
|
||||
|
||||
function removeTag(container, name) {
|
||||
var chip = container.querySelector('[data-tagcombobox-chip="' + CSS.escape(name) + '"]');
|
||||
if (chip) chip.remove();
|
||||
|
||||
var hiddenInputs = container.querySelectorAll('[data-tagcombobox-hidden-inputs] input[type="hidden"]');
|
||||
for (var i = 0; i < hiddenInputs.length; i++) {
|
||||
if (hiddenInputs[i].value.toLowerCase() === name.toLowerCase()) {
|
||||
hiddenInputs[i].remove();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
updateDropdownState(container);
|
||||
}
|
||||
|
||||
function updateDropdownState(container) {
|
||||
var items = container.querySelectorAll("[data-tagcombobox-item]");
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
var itemName = items[i].getAttribute("data-tagcombobox-item");
|
||||
var check = items[i].querySelector("[data-tagcombobox-check]");
|
||||
var selected = isTagSelected(container, itemName);
|
||||
if (check) {
|
||||
check.classList.toggle("opacity-100", selected);
|
||||
check.classList.toggle("opacity-0", !selected);
|
||||
}
|
||||
items[i].classList.toggle("font-medium", selected);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Dropdown ---
|
||||
function showDropdown(container) {
|
||||
var dropdown = container.querySelector("[data-tagcombobox-dropdown]");
|
||||
dropdown.classList.remove("hidden");
|
||||
filterDropdown(container);
|
||||
}
|
||||
|
||||
function hideDropdown(container) {
|
||||
var dropdown = container.querySelector("[data-tagcombobox-dropdown]");
|
||||
dropdown.classList.add("hidden");
|
||||
clearHighlight(container);
|
||||
}
|
||||
|
||||
function isDropdownVisible(container) {
|
||||
var dropdown = container.querySelector("[data-tagcombobox-dropdown]");
|
||||
return !dropdown.classList.contains("hidden");
|
||||
}
|
||||
|
||||
function filterDropdown(container) {
|
||||
var textInput = container.querySelector("[data-tagcombobox-text-input]");
|
||||
var query = (textInput.value || "").toLowerCase().trim();
|
||||
var items = container.querySelectorAll("[data-tagcombobox-item]");
|
||||
var createOption = container.querySelector("[data-tagcombobox-create]");
|
||||
var createLabel = container.querySelector("[data-tagcombobox-create-label]");
|
||||
var hasExactMatch = false;
|
||||
var visibleCount = 0;
|
||||
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
var itemName = items[i].getAttribute("data-tagcombobox-item");
|
||||
var matches = query === "" || itemName.toLowerCase().indexOf(query) !== -1;
|
||||
items[i].style.display = matches ? "" : "none";
|
||||
if (matches) visibleCount++;
|
||||
if (itemName.toLowerCase() === query) hasExactMatch = true;
|
||||
}
|
||||
|
||||
// Show "Create" option if typed text doesn't exactly match an existing tag
|
||||
if (query && !hasExactMatch) {
|
||||
createLabel.textContent = query;
|
||||
createOption.style.display = "flex";
|
||||
createOption.classList.remove("hidden");
|
||||
visibleCount++;
|
||||
} else {
|
||||
createOption.style.display = "none";
|
||||
createOption.classList.add("hidden");
|
||||
}
|
||||
|
||||
// Show dropdown if items to show
|
||||
var dropdown = container.querySelector("[data-tagcombobox-dropdown]");
|
||||
if (visibleCount > 0) {
|
||||
dropdown.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
// --- Keyboard navigation ---
|
||||
function getVisibleItems(container) {
|
||||
var all = container.querySelectorAll("[data-tagcombobox-item], [data-tagcombobox-create]");
|
||||
var visible = [];
|
||||
for (var i = 0; i < all.length; i++) {
|
||||
if (all[i].style.display !== "none" && !all[i].classList.contains("hidden")) {
|
||||
visible.push(all[i]);
|
||||
}
|
||||
}
|
||||
return visible;
|
||||
}
|
||||
|
||||
function getHighlightedIndex(container) {
|
||||
var items = getVisibleItems(container);
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
if (items[i].hasAttribute("data-tagcombobox-highlighted")) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function clearHighlight(container) {
|
||||
var highlighted = container.querySelectorAll("[data-tagcombobox-highlighted]");
|
||||
for (var i = 0; i < highlighted.length; i++) {
|
||||
highlighted[i].removeAttribute("data-tagcombobox-highlighted");
|
||||
highlighted[i].classList.remove("bg-accent", "text-accent-foreground");
|
||||
}
|
||||
}
|
||||
|
||||
function highlightItem(container, index) {
|
||||
clearHighlight(container);
|
||||
var items = getVisibleItems(container);
|
||||
if (index >= 0 && index < items.length) {
|
||||
items[index].setAttribute("data-tagcombobox-highlighted", "");
|
||||
items[index].classList.add("bg-accent", "text-accent-foreground");
|
||||
items[index].scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
}
|
||||
|
||||
function selectHighlighted(container) {
|
||||
var items = getVisibleItems(container);
|
||||
var index = getHighlightedIndex(container);
|
||||
if (index === -1 && items.length > 0) index = 0;
|
||||
if (index === -1) return;
|
||||
|
||||
var item = items[index];
|
||||
if (item.hasAttribute("data-tagcombobox-create")) {
|
||||
// Create new tag
|
||||
var label = item.querySelector("[data-tagcombobox-create-label]").textContent;
|
||||
addTag(container, label);
|
||||
} else {
|
||||
var name = item.getAttribute("data-tagcombobox-item");
|
||||
if (isTagSelected(container, name)) {
|
||||
removeTag(container, name);
|
||||
} else {
|
||||
addTag(container, name);
|
||||
}
|
||||
}
|
||||
|
||||
var textInput = container.querySelector("[data-tagcombobox-text-input]");
|
||||
textInput.value = "";
|
||||
filterDropdown(container);
|
||||
}
|
||||
|
||||
// --- Event listeners ---
|
||||
|
||||
// Click on input area -> focus text input
|
||||
document.addEventListener("click", function (e) {
|
||||
// Remove button
|
||||
var removeBtn = e.target.closest("[data-tagcombobox-remove]");
|
||||
if (removeBtn && !removeBtn.disabled) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
var container = removeBtn.closest("[data-tagcombobox]");
|
||||
var name = removeBtn.getAttribute("data-tagcombobox-remove");
|
||||
removeTag(container, name);
|
||||
return;
|
||||
}
|
||||
|
||||
// Dropdown item click
|
||||
var item = e.target.closest("[data-tagcombobox-item]");
|
||||
if (item) {
|
||||
e.preventDefault();
|
||||
var container = item.closest("[data-tagcombobox]");
|
||||
var name = item.getAttribute("data-tagcombobox-item");
|
||||
if (isTagSelected(container, name)) {
|
||||
removeTag(container, name);
|
||||
} else {
|
||||
addTag(container, name);
|
||||
}
|
||||
var textInput = container.querySelector("[data-tagcombobox-text-input]");
|
||||
textInput.value = "";
|
||||
filterDropdown(container);
|
||||
textInput.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
// Create option click
|
||||
var createOpt = e.target.closest("[data-tagcombobox-create]");
|
||||
if (createOpt) {
|
||||
e.preventDefault();
|
||||
var container = createOpt.closest("[data-tagcombobox]");
|
||||
var label = createOpt.querySelector("[data-tagcombobox-create-label]").textContent;
|
||||
addTag(container, label);
|
||||
var textInput = container.querySelector("[data-tagcombobox-text-input]");
|
||||
textInput.value = "";
|
||||
filterDropdown(container);
|
||||
textInput.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
// Click on input area -> focus
|
||||
var inputArea = e.target.closest("[data-tagcombobox-input-area]");
|
||||
if (inputArea) {
|
||||
var container = inputArea.closest("[data-tagcombobox]");
|
||||
var textInput = container.querySelector("[data-tagcombobox-text-input]");
|
||||
if (textInput && !textInput.disabled) {
|
||||
textInput.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Click outside -> close all dropdowns
|
||||
var allContainers = document.querySelectorAll("[data-tagcombobox]");
|
||||
for (var i = 0; i < allContainers.length; i++) {
|
||||
if (!allContainers[i].contains(e.target)) {
|
||||
hideDropdown(allContainers[i]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Focus -> show dropdown
|
||||
document.addEventListener("focusin", function (e) {
|
||||
var textInput = e.target.closest("[data-tagcombobox-text-input]");
|
||||
if (!textInput) return;
|
||||
var container = textInput.closest("[data-tagcombobox]");
|
||||
if (container) {
|
||||
updateDropdownState(container);
|
||||
showDropdown(container);
|
||||
}
|
||||
});
|
||||
|
||||
// Input -> filter
|
||||
document.addEventListener("input", function (e) {
|
||||
var textInput = e.target.closest("[data-tagcombobox-text-input]");
|
||||
if (!textInput) return;
|
||||
var container = textInput.closest("[data-tagcombobox]");
|
||||
if (container) {
|
||||
filterDropdown(container);
|
||||
clearHighlight(container);
|
||||
}
|
||||
});
|
||||
|
||||
// Keyboard navigation
|
||||
document.addEventListener("keydown", function (e) {
|
||||
var textInput = e.target.closest("[data-tagcombobox-text-input]");
|
||||
if (!textInput) return;
|
||||
var container = textInput.closest("[data-tagcombobox]");
|
||||
if (!container) return;
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
if (!isDropdownVisible(container)) {
|
||||
showDropdown(container);
|
||||
return;
|
||||
}
|
||||
var items = getVisibleItems(container);
|
||||
var idx = getHighlightedIndex(container);
|
||||
highlightItem(container, Math.min(idx + 1, items.length - 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
var idx = getHighlightedIndex(container);
|
||||
if (idx > 0) {
|
||||
highlightItem(container, idx - 1);
|
||||
}
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (isDropdownVisible(container)) {
|
||||
var idx = getHighlightedIndex(container);
|
||||
if (idx >= 0) {
|
||||
selectHighlighted(container);
|
||||
} else {
|
||||
// If text typed but nothing highlighted, try create or select first match
|
||||
var query = textInput.value.trim();
|
||||
if (query) {
|
||||
var createOpt = container.querySelector("[data-tagcombobox-create]");
|
||||
if (createOpt && createOpt.style.display !== "none" && !createOpt.classList.contains("hidden")) {
|
||||
addTag(container, query);
|
||||
textInput.value = "";
|
||||
filterDropdown(container);
|
||||
} else {
|
||||
// Select first visible item
|
||||
var items = getVisibleItems(container);
|
||||
if (items.length > 0) {
|
||||
highlightItem(container, 0);
|
||||
selectHighlighted(container);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (e.key === "Escape") {
|
||||
hideDropdown(container);
|
||||
} else if (e.key === "Backspace" && textInput.value === "") {
|
||||
// Remove last chip
|
||||
var chips = container.querySelectorAll("[data-tagcombobox-chip]");
|
||||
if (chips.length > 0) {
|
||||
var lastChip = chips[chips.length - 1];
|
||||
var name = lastChip.getAttribute("data-tagcombobox-chip");
|
||||
removeTag(container, name);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Form reset
|
||||
document.addEventListener("reset", function (e) {
|
||||
if (!e.target.matches("form")) return;
|
||||
e.target.querySelectorAll("[data-tagcombobox]").forEach(function (container) {
|
||||
container.querySelectorAll("[data-tagcombobox-chip]").forEach(function (chip) {
|
||||
chip.remove();
|
||||
});
|
||||
container.querySelectorAll('[data-tagcombobox-hidden-inputs] input[type="hidden"]').forEach(function (inp) {
|
||||
inp.remove();
|
||||
});
|
||||
var textInput = container.querySelector("[data-tagcombobox-text-input]");
|
||||
if (textInput) textInput.value = "";
|
||||
updateDropdownState(container);
|
||||
hideDropdown(container);
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
|
@ -5,7 +5,6 @@ import (
|
|||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ctxkeys"
|
||||
|
|
@ -697,7 +696,9 @@ func (h *SpaceHandler) CreateExpense(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
ui.Render(w, r, pages.ExpenseCreatedResponse(spaceID, expenses, balance, totalAllocated, 1, totalPages))
|
||||
// Re-fetch tags (may have been auto-created)
|
||||
refreshedTags, _ := h.tagService.GetTagsForSpace(spaceID)
|
||||
ui.Render(w, r, pages.ExpenseCreatedResponse(spaceID, expenses, balance, totalAllocated, refreshedTags, 1, totalPages))
|
||||
|
||||
// OOB-swap the item selector with fresh data (items may have been deleted/checked)
|
||||
listsWithItems, err := h.listService.GetListsWithUncheckedItems(spaceID)
|
||||
|
|
@ -832,7 +833,8 @@ func (h *SpaceHandler) UpdateExpense(w http.ResponseWriter, r *http.Request) {
|
|||
balance -= totalAllocated
|
||||
|
||||
methods, _ := h.methodService.GetMethodsForSpace(spaceID)
|
||||
ui.Render(w, r, pages.ExpenseUpdatedResponse(spaceID, expWithTagsAndMethod, balance, totalAllocated, methods))
|
||||
updatedTags, _ := h.tagService.GetTagsForSpace(spaceID)
|
||||
ui.Render(w, r, pages.ExpenseUpdatedResponse(spaceID, expWithTagsAndMethod, balance, totalAllocated, methods, updatedTags))
|
||||
}
|
||||
|
||||
func (h *SpaceHandler) DeleteExpense(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -978,7 +980,8 @@ func (h *SpaceHandler) GetExpensesList(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
methods, _ := h.methodService.GetMethodsForSpace(spaceID)
|
||||
ui.Render(w, r, pages.ExpensesListContent(spaceID, expenses, methods, page, totalPages))
|
||||
paginatedTags, _ := h.tagService.GetTagsForSpace(spaceID)
|
||||
ui.Render(w, r, pages.ExpensesListContent(spaceID, expenses, methods, paginatedTags, page, totalPages))
|
||||
}
|
||||
|
||||
func (h *SpaceHandler) GetShoppingListItems(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -1779,16 +1782,17 @@ func (h *SpaceHandler) CreateRecurringExpense(w http.ResponseWriter, r *http.Req
|
|||
}
|
||||
|
||||
// Fetch tags/method for the response
|
||||
spaceTags, _ := h.tagService.GetTagsForSpace(spaceID)
|
||||
tagsMap, _ := h.recurringService.GetRecurringExpensesWithTagsAndMethodsForSpace(spaceID)
|
||||
for _, item := range tagsMap {
|
||||
if item.ID == re.ID {
|
||||
ui.Render(w, r, recurring.RecurringItem(spaceID, item, nil))
|
||||
ui.Render(w, r, recurring.RecurringItem(spaceID, item, nil, spaceTags))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: render without tags
|
||||
ui.Render(w, r, recurring.RecurringItem(spaceID, &model.RecurringExpenseWithTagsAndMethod{RecurringExpense: *re}, nil))
|
||||
ui.Render(w, r, recurring.RecurringItem(spaceID, &model.RecurringExpenseWithTagsAndMethod{RecurringExpense: *re}, nil, spaceTags))
|
||||
}
|
||||
|
||||
func (h *SpaceHandler) UpdateRecurringExpense(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -1892,16 +1896,17 @@ func (h *SpaceHandler) UpdateRecurringExpense(w http.ResponseWriter, r *http.Req
|
|||
}
|
||||
|
||||
// Build response with tags/method
|
||||
updateSpaceTags, _ := h.tagService.GetTagsForSpace(spaceID)
|
||||
tagsMapResult, _ := h.recurringService.GetRecurringExpensesWithTagsAndMethodsForSpace(spaceID)
|
||||
for _, item := range tagsMapResult {
|
||||
if item.ID == updated.ID {
|
||||
methods, _ := h.methodService.GetMethodsForSpace(spaceID)
|
||||
ui.Render(w, r, recurring.RecurringItem(spaceID, item, methods))
|
||||
ui.Render(w, r, recurring.RecurringItem(spaceID, item, methods, updateSpaceTags))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ui.Render(w, r, recurring.RecurringItem(spaceID, &model.RecurringExpenseWithTagsAndMethod{RecurringExpense: *updated}, nil))
|
||||
ui.Render(w, r, recurring.RecurringItem(spaceID, &model.RecurringExpenseWithTagsAndMethod{RecurringExpense: *updated}, nil, updateSpaceTags))
|
||||
}
|
||||
|
||||
func (h *SpaceHandler) DeleteRecurringExpense(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -1943,20 +1948,63 @@ func (h *SpaceHandler) ToggleRecurringExpense(w http.ResponseWriter, r *http.Req
|
|||
return
|
||||
}
|
||||
|
||||
toggleSpaceTags, _ := h.tagService.GetTagsForSpace(spaceID)
|
||||
tagsMapResult, _ := h.recurringService.GetRecurringExpensesWithTagsAndMethodsForSpace(spaceID)
|
||||
for _, item := range tagsMapResult {
|
||||
if item.ID == updated.ID {
|
||||
methods, _ := h.methodService.GetMethodsForSpace(spaceID)
|
||||
ui.Render(w, r, recurring.RecurringItem(spaceID, item, methods))
|
||||
ui.Render(w, r, recurring.RecurringItem(spaceID, item, methods, toggleSpaceTags))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ui.Render(w, r, recurring.RecurringItem(spaceID, &model.RecurringExpenseWithTagsAndMethod{RecurringExpense: *updated}, nil))
|
||||
ui.Render(w, r, recurring.RecurringItem(spaceID, &model.RecurringExpenseWithTagsAndMethod{RecurringExpense: *updated}, nil, toggleSpaceTags))
|
||||
}
|
||||
|
||||
// --- Budgets ---
|
||||
|
||||
// processTagNames normalizes tag names, deduplicates them, and resolves them
|
||||
// to tag IDs. Tags that don't exist are auto-created.
|
||||
func (h *SpaceHandler) processTagNames(spaceID string, tagNames []string) ([]string, error) {
|
||||
existingTags, err := h.tagService.GetTagsForSpace(spaceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
existingTagsMap := make(map[string]string)
|
||||
for _, t := range existingTags {
|
||||
existingTagsMap[t.Name] = t.ID
|
||||
}
|
||||
|
||||
var finalTagIDs []string
|
||||
processedTags := make(map[string]bool)
|
||||
|
||||
for _, rawTagName := range tagNames {
|
||||
tagName := service.NormalizeTagName(rawTagName)
|
||||
if tagName == "" {
|
||||
continue
|
||||
}
|
||||
if processedTags[tagName] {
|
||||
continue
|
||||
}
|
||||
|
||||
if id, exists := existingTagsMap[tagName]; exists {
|
||||
finalTagIDs = append(finalTagIDs, id)
|
||||
} else {
|
||||
newTag, err := h.tagService.CreateTag(spaceID, tagName, nil)
|
||||
if err != nil {
|
||||
slog.Error("failed to create new tag", "error", err, "tag_name", tagName)
|
||||
continue
|
||||
}
|
||||
finalTagIDs = append(finalTagIDs, newTag.ID)
|
||||
existingTagsMap[tagName] = newTag.ID
|
||||
}
|
||||
processedTags[tagName] = true
|
||||
}
|
||||
|
||||
return finalTagIDs, nil
|
||||
}
|
||||
|
||||
func (h *SpaceHandler) getBudgetForSpace(w http.ResponseWriter, spaceID, budgetID string) *model.Budget {
|
||||
budget, err := h.budgetService.GetBudget(budgetID)
|
||||
if err != nil {
|
||||
|
|
@ -2004,24 +2052,26 @@ func (h *SpaceHandler) CreateBudget(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
tagIDsStr := r.FormValue("tag_ids")
|
||||
tagNames := r.Form["tags"]
|
||||
amountStr := r.FormValue("amount")
|
||||
periodStr := r.FormValue("period")
|
||||
startDateStr := r.FormValue("start_date")
|
||||
endDateStr := r.FormValue("end_date")
|
||||
|
||||
var tagIDs []string
|
||||
if tagIDsStr != "" {
|
||||
for _, id := range strings.Split(tagIDsStr, ",") {
|
||||
id = strings.TrimSpace(id)
|
||||
if id != "" {
|
||||
tagIDs = append(tagIDs, id)
|
||||
}
|
||||
}
|
||||
if len(tagNames) == 0 || amountStr == "" || periodStr == "" || startDateStr == "" {
|
||||
ui.RenderError(w, r, "All required fields must be provided.", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
|
||||
if len(tagIDs) == 0 || amountStr == "" || periodStr == "" || startDateStr == "" {
|
||||
ui.RenderError(w, r, "All required fields must be provided.", http.StatusUnprocessableEntity)
|
||||
tagIDs, err := h.processTagNames(spaceID, tagNames)
|
||||
if err != nil {
|
||||
slog.Error("failed to process tag names", "error", err, "space_id", spaceID)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if len(tagIDs) == 0 {
|
||||
ui.RenderError(w, r, "At least one valid tag is required.", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -2082,24 +2132,26 @@ func (h *SpaceHandler) UpdateBudget(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
tagIDsStr := r.FormValue("tag_ids")
|
||||
tagNames := r.Form["tags"]
|
||||
amountStr := r.FormValue("amount")
|
||||
periodStr := r.FormValue("period")
|
||||
startDateStr := r.FormValue("start_date")
|
||||
endDateStr := r.FormValue("end_date")
|
||||
|
||||
var tagIDs []string
|
||||
if tagIDsStr != "" {
|
||||
for _, id := range strings.Split(tagIDsStr, ",") {
|
||||
id = strings.TrimSpace(id)
|
||||
if id != "" {
|
||||
tagIDs = append(tagIDs, id)
|
||||
}
|
||||
}
|
||||
if len(tagNames) == 0 || amountStr == "" || periodStr == "" || startDateStr == "" {
|
||||
ui.RenderError(w, r, "All required fields must be provided.", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
|
||||
if len(tagIDs) == 0 || amountStr == "" || periodStr == "" || startDateStr == "" {
|
||||
ui.RenderError(w, r, "All required fields must be provided.", http.StatusUnprocessableEntity)
|
||||
tagIDs, err := h.processTagNames(spaceID, tagNames)
|
||||
if err != nil {
|
||||
slog.Error("failed to process tag names", "error", err, "space_id", spaceID)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if len(tagIDs) == 0 {
|
||||
ui.RenderError(w, r, "At least one valid tag is required.", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import (
|
|||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/label"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/paymentmethod"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/radio"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/tagsinput"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/tagcombobox"
|
||||
)
|
||||
|
||||
type AddExpenseFormProps struct {
|
||||
|
|
@ -139,16 +139,11 @@ templ AddExpenseForm(props AddExpenseFormProps) {
|
|||
@label.Label(label.Props{For: "new-expense-tags"}) {
|
||||
Tags (Optional)
|
||||
}
|
||||
<datalist id="available-tags">
|
||||
for _, tag := range props.Tags {
|
||||
<option value={ tag.Name }></option>
|
||||
}
|
||||
</datalist>
|
||||
@tagsinput.TagsInput(tagsinput.Props{
|
||||
@tagcombobox.TagCombobox(tagcombobox.Props{
|
||||
ID: "new-expense-tags",
|
||||
Name: "tags",
|
||||
Placeholder: "Add tags (press enter)",
|
||||
Attributes: templ.Attributes{"list": "available-tags"},
|
||||
Tags: props.Tags,
|
||||
Placeholder: "Search or create tags...",
|
||||
})
|
||||
</div>
|
||||
// Payment Method
|
||||
|
|
@ -163,7 +158,7 @@ templ AddExpenseForm(props AddExpenseFormProps) {
|
|||
</form>
|
||||
}
|
||||
|
||||
templ EditExpenseForm(spaceID string, exp *model.ExpenseWithTagsAndMethod, methods []*model.PaymentMethod) {
|
||||
templ EditExpenseForm(spaceID string, exp *model.ExpenseWithTagsAndMethod, methods []*model.PaymentMethod, tags []*model.Tag) {
|
||||
{{ editDialogID := "edit-expense-" + exp.ID }}
|
||||
{{ tagValues := make([]string, len(exp.Tags)) }}
|
||||
for i, t := range exp.Tags {
|
||||
|
|
@ -248,11 +243,12 @@ templ EditExpenseForm(spaceID string, exp *model.ExpenseWithTagsAndMethod, metho
|
|||
@label.Label(label.Props{For: "edit-tags-" + exp.ID}) {
|
||||
Tags (Optional)
|
||||
}
|
||||
@tagsinput.TagsInput(tagsinput.Props{
|
||||
@tagcombobox.TagCombobox(tagcombobox.Props{
|
||||
ID: "edit-tags-" + exp.ID,
|
||||
Name: "tags",
|
||||
Value: tagValues,
|
||||
Placeholder: "Add tags (press enter)",
|
||||
Tags: tags,
|
||||
Placeholder: "Search or create tags...",
|
||||
})
|
||||
</div>
|
||||
// Payment Method
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import (
|
|||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/paymentmethod"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/radio"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/selectbox"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/tagsinput"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/tagcombobox"
|
||||
)
|
||||
|
||||
func frequencyLabel(f model.Frequency) string {
|
||||
|
|
@ -34,7 +34,7 @@ func frequencyLabel(f model.Frequency) string {
|
|||
}
|
||||
}
|
||||
|
||||
templ RecurringItem(spaceID string, re *model.RecurringExpenseWithTagsAndMethod, methods []*model.PaymentMethod) {
|
||||
templ RecurringItem(spaceID string, re *model.RecurringExpenseWithTagsAndMethod, methods []*model.PaymentMethod, tags []*model.Tag) {
|
||||
{{ editDialogID := "edit-recurring-" + re.ID }}
|
||||
{{ delDialogID := "del-recurring-" + re.ID }}
|
||||
<div id={ "recurring-" + re.ID } class="p-4 flex justify-between items-start gap-2">
|
||||
|
|
@ -113,7 +113,7 @@ templ RecurringItem(spaceID string, re *model.RecurringExpenseWithTagsAndMethod,
|
|||
Update the details of this recurring transaction.
|
||||
}
|
||||
}
|
||||
@EditRecurringForm(spaceID, re, methods)
|
||||
@EditRecurringForm(spaceID, re, methods, tags)
|
||||
}
|
||||
}
|
||||
// Delete button
|
||||
|
|
@ -269,16 +269,11 @@ templ AddRecurringForm(spaceID string, tags []*model.Tag, methods []*model.Payme
|
|||
@label.Label(label.Props{For: "recurring-tags"}) {
|
||||
Tags
|
||||
}
|
||||
<datalist id="recurring-available-tags">
|
||||
for _, t := range tags {
|
||||
<option value={ t.Name }></option>
|
||||
}
|
||||
</datalist>
|
||||
@tagsinput.TagsInput(tagsinput.Props{
|
||||
@tagcombobox.TagCombobox(tagcombobox.Props{
|
||||
ID: "recurring-tags",
|
||||
Name: "tags",
|
||||
Placeholder: "Add tags (press enter)",
|
||||
Attributes: templ.Attributes{"list": "recurring-available-tags"},
|
||||
Tags: tags,
|
||||
Placeholder: "Search or create tags...",
|
||||
})
|
||||
</div>
|
||||
// Payment Method
|
||||
|
|
@ -291,7 +286,7 @@ templ AddRecurringForm(spaceID string, tags []*model.Tag, methods []*model.Payme
|
|||
</form>
|
||||
}
|
||||
|
||||
templ EditRecurringForm(spaceID string, re *model.RecurringExpenseWithTagsAndMethod, methods []*model.PaymentMethod) {
|
||||
templ EditRecurringForm(spaceID string, re *model.RecurringExpenseWithTagsAndMethod, methods []*model.PaymentMethod, tags []*model.Tag) {
|
||||
{{ editDialogID := "edit-recurring-" + re.ID }}
|
||||
{{ tagValues := make([]string, len(re.Tags)) }}
|
||||
for i, t := range re.Tags {
|
||||
|
|
@ -422,11 +417,12 @@ templ EditRecurringForm(spaceID string, re *model.RecurringExpenseWithTagsAndMet
|
|||
@label.Label(label.Props{For: "edit-recurring-tags-" + re.ID}) {
|
||||
Tags
|
||||
}
|
||||
@tagsinput.TagsInput(tagsinput.Props{
|
||||
@tagcombobox.TagCombobox(tagcombobox.Props{
|
||||
ID: "edit-recurring-tags-" + re.ID,
|
||||
Name: "tags",
|
||||
Value: tagValues,
|
||||
Placeholder: "Add tags (press enter)",
|
||||
Tags: tags,
|
||||
Placeholder: "Search or create tags...",
|
||||
})
|
||||
</div>
|
||||
// Payment Method
|
||||
|
|
|
|||
158
internal/ui/components/tagcombobox/tagcombobox.templ
Normal file
158
internal/ui/components/tagcombobox/tagcombobox.templ
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
package tagcombobox
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/model"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/badge"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/utils"
|
||||
)
|
||||
|
||||
type Props struct {
|
||||
ID string
|
||||
Name string // form field name (default "tags")
|
||||
Value []string // pre-selected tag names
|
||||
Tags []*model.Tag // all available tags in the space
|
||||
Placeholder string
|
||||
HasError bool
|
||||
Disabled bool
|
||||
Form string // associate with external form
|
||||
}
|
||||
|
||||
func (p Props) fieldName() string {
|
||||
if p.Name != "" {
|
||||
return p.Name
|
||||
}
|
||||
return "tags"
|
||||
}
|
||||
|
||||
func (p Props) isSelected(tagName string) bool {
|
||||
lower := strings.ToLower(tagName)
|
||||
for _, v := range p.Value {
|
||||
if strings.ToLower(v) == lower {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
templ TagCombobox(props Props) {
|
||||
<div
|
||||
id={ props.ID + "-container" }
|
||||
class={
|
||||
utils.TwMerge(
|
||||
"relative w-full",
|
||||
),
|
||||
}
|
||||
data-tagcombobox
|
||||
data-tagcombobox-name={ props.fieldName() }
|
||||
data-tagcombobox-form={ props.Form }
|
||||
>
|
||||
// Main input area styled like tagsinput
|
||||
<div
|
||||
class={
|
||||
utils.TwMerge(
|
||||
"flex items-center flex-wrap gap-2 p-2 rounded-md border border-input bg-transparent shadow-xs transition-[color,box-shadow] outline-none cursor-text",
|
||||
"dark:bg-input/30",
|
||||
"focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px]",
|
||||
utils.If(props.Disabled, "opacity-50 cursor-not-allowed"),
|
||||
"w-full min-h-[38px]",
|
||||
utils.If(props.HasError, "border-destructive ring-destructive/20 dark:ring-destructive/40"),
|
||||
),
|
||||
}
|
||||
data-tagcombobox-input-area
|
||||
>
|
||||
// Selected tag chips
|
||||
<div class="flex items-center flex-wrap gap-2" data-tagcombobox-chips>
|
||||
for _, val := range props.Value {
|
||||
@badge.Badge(badge.Props{
|
||||
Attributes: templ.Attributes{"data-tagcombobox-chip": val},
|
||||
}) {
|
||||
<span>{ val }</span>
|
||||
<button
|
||||
type="button"
|
||||
class="ml-1 text-current hover:text-destructive disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
|
||||
disabled?={ props.Disabled }
|
||||
data-tagcombobox-remove={ val }
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
// Text input for searching/typing
|
||||
<input
|
||||
type="text"
|
||||
id={ props.ID }
|
||||
class="border-0 shadow-none focus-visible:ring-0 focus-visible:outline-none h-auto py-0 px-0 bg-transparent rounded-none min-h-0 disabled:opacity-100 dark:bg-transparent flex-1 min-w-[80px] text-sm"
|
||||
placeholder={ props.Placeholder }
|
||||
disabled?={ props.Disabled }
|
||||
autocomplete="off"
|
||||
data-tagcombobox-text-input
|
||||
/>
|
||||
</div>
|
||||
// Dropdown
|
||||
<div
|
||||
class="absolute z-50 mt-1 w-full rounded-md border bg-popover text-popover-foreground shadow-md hidden max-h-60 overflow-y-auto"
|
||||
data-tagcombobox-dropdown
|
||||
>
|
||||
for _, tag := range props.Tags {
|
||||
<div
|
||||
class={
|
||||
"flex items-center gap-2 px-3 py-2 text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground",
|
||||
templ.KV("font-medium", props.isSelected(tag.Name)),
|
||||
}
|
||||
data-tagcombobox-item={ tag.Name }
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class={
|
||||
"h-4 w-4 shrink-0",
|
||||
templ.KV("opacity-100", props.isSelected(tag.Name)),
|
||||
templ.KV("opacity-0", !props.isSelected(tag.Name)),
|
||||
}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
data-tagcombobox-check
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
if tag.Color != nil {
|
||||
<span class="inline-block w-3 h-3 rounded-full shrink-0" style={ "background-color: " + *tag.Color }></span>
|
||||
}
|
||||
<span data-tagcombobox-item-label>{ tag.Name }</span>
|
||||
</div>
|
||||
}
|
||||
// "Create new" option (hidden by default, shown when typing something new)
|
||||
<div
|
||||
class="hidden items-center gap-2 px-3 py-2 text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground text-muted-foreground"
|
||||
data-tagcombobox-create
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4"></path>
|
||||
</svg>
|
||||
<span>Create "<span data-tagcombobox-create-label></span>"</span>
|
||||
</div>
|
||||
</div>
|
||||
// Hidden inputs for form submission
|
||||
<div data-tagcombobox-hidden-inputs>
|
||||
for _, val := range props.Value {
|
||||
<input
|
||||
type="hidden"
|
||||
name={ props.fieldName() }
|
||||
value={ val }
|
||||
if props.Form != "" {
|
||||
form={ props.Form }
|
||||
}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ Script() {
|
||||
<script defer nonce={ templ.GetNonce(ctx) } src={ utils.ScriptURL("/assets/js/tagcombobox.js") }></script>
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ import "git.juancwu.dev/juancwu/budgit/internal/ui/components/datepicker"
|
|||
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/progress"
|
||||
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/selectbox"
|
||||
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/tagsinput"
|
||||
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/tagcombobox"
|
||||
import "fmt"
|
||||
import "time"
|
||||
|
||||
|
|
@ -53,6 +54,7 @@ templ Base(props ...SEOProps) {
|
|||
@progress.Script()
|
||||
@tagsinput.Script()
|
||||
@selectbox.Script()
|
||||
@tagcombobox.Script()
|
||||
// Site-wide enhancements
|
||||
@themeScript()
|
||||
// Must run before body to prevent flash
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import (
|
|||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/input"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/label"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/radio"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/selectbox"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/tagcombobox"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
|
||||
)
|
||||
|
||||
|
|
@ -37,15 +37,6 @@ func progressBarColor(status model.BudgetStatus) string {
|
|||
}
|
||||
}
|
||||
|
||||
func budgetTagSelected(tags []*model.Tag, tagID string) bool {
|
||||
for _, t := range tags {
|
||||
if t.ID == tagID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
templ SpaceBudgetsPage(space *model.Space, budgets []*model.BudgetWithSpent, tags []*model.Tag) {
|
||||
@layouts.Space("Budgets", space) {
|
||||
<div class="space-y-4">
|
||||
|
|
@ -190,23 +181,17 @@ templ AddBudgetForm(spaceID string, tags []*model.Tag) {
|
|||
class="space-y-4"
|
||||
>
|
||||
@csrf.Token()
|
||||
// Tag selector (multi-select)
|
||||
// Tag selector
|
||||
<div>
|
||||
@label.Label(label.Props{}) {
|
||||
@label.Label(label.Props{For: "budget-tags"}) {
|
||||
Tags
|
||||
}
|
||||
@selectbox.SelectBox(selectbox.Props{ID: "budget-tags", Multiple: true}) {
|
||||
@selectbox.Trigger(selectbox.TriggerProps{Name: "tag_ids", Multiple: true, ShowPills: true, SelectedCountText: "{n} tags selected"}) {
|
||||
@selectbox.Value(selectbox.ValueProps{Placeholder: "Select tags..."})
|
||||
}
|
||||
@selectbox.Content() {
|
||||
for _, t := range tags {
|
||||
@selectbox.Item(selectbox.ItemProps{Value: t.ID}) {
|
||||
{ t.Name }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@tagcombobox.TagCombobox(tagcombobox.Props{
|
||||
ID: "budget-tags",
|
||||
Name: "tags",
|
||||
Tags: tags,
|
||||
Placeholder: "Search or create tags...",
|
||||
})
|
||||
</div>
|
||||
// Amount
|
||||
<div>
|
||||
|
|
@ -290,6 +275,10 @@ templ AddBudgetForm(spaceID string, tags []*model.Tag) {
|
|||
|
||||
templ EditBudgetForm(spaceID string, b *model.BudgetWithSpent, tags []*model.Tag) {
|
||||
{{ editDialogID := "edit-budget-" + b.ID }}
|
||||
{{ budgetTagNames := make([]string, len(b.Tags)) }}
|
||||
for i, t := range b.Tags {
|
||||
{{ budgetTagNames[i] = t.Name }}
|
||||
}
|
||||
<form
|
||||
hx-patch={ fmt.Sprintf("/app/spaces/%s/budgets/%s", spaceID, b.ID) }
|
||||
hx-target="#budgets-list-wrapper"
|
||||
|
|
@ -298,23 +287,18 @@ templ EditBudgetForm(spaceID string, b *model.BudgetWithSpent, tags []*model.Tag
|
|||
class="space-y-4"
|
||||
>
|
||||
@csrf.Token()
|
||||
// Tag selector (multi-select with pre-selected tags)
|
||||
// Tag selector
|
||||
<div>
|
||||
@label.Label(label.Props{}) {
|
||||
@label.Label(label.Props{For: "edit-budget-tags-" + b.ID}) {
|
||||
Tags
|
||||
}
|
||||
@selectbox.SelectBox(selectbox.Props{ID: "edit-budget-tags-" + b.ID, Multiple: true}) {
|
||||
@selectbox.Trigger(selectbox.TriggerProps{Name: "tag_ids", Multiple: true, ShowPills: true, SelectedCountText: "{n} tags selected"}) {
|
||||
@selectbox.Value(selectbox.ValueProps{Placeholder: "Select tags..."})
|
||||
}
|
||||
@selectbox.Content() {
|
||||
for _, t := range tags {
|
||||
@selectbox.Item(selectbox.ItemProps{Value: t.ID, Selected: budgetTagSelected(b.Tags, t.ID)}) {
|
||||
{ t.Name }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@tagcombobox.TagCombobox(tagcombobox.Props{
|
||||
ID: "edit-budget-tags-" + b.ID,
|
||||
Name: "tags",
|
||||
Value: budgetTagNames,
|
||||
Tags: tags,
|
||||
Placeholder: "Search or create tags...",
|
||||
})
|
||||
</div>
|
||||
// Amount
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -48,21 +48,21 @@ templ SpaceExpensesPage(space *model.Space, expenses []*model.ExpenseWithTagsAnd
|
|||
// List of expenses
|
||||
<div class="border rounded-lg">
|
||||
<div id="expenses-list-wrapper">
|
||||
@ExpensesListContent(space.ID, expenses, methods, currentPage, totalPages)
|
||||
@ExpensesListContent(space.ID, expenses, methods, tags, currentPage, totalPages)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
templ ExpensesListContent(spaceID string, expenses []*model.ExpenseWithTagsAndMethod, methods []*model.PaymentMethod, currentPage, totalPages int) {
|
||||
templ ExpensesListContent(spaceID string, expenses []*model.ExpenseWithTagsAndMethod, methods []*model.PaymentMethod, tags []*model.Tag, currentPage, totalPages int) {
|
||||
<h2 class="text-lg font-semibold p-4">History</h2>
|
||||
<div id="expenses-list" class="divide-y">
|
||||
if len(expenses) == 0 {
|
||||
<p class="p-4 text-sm text-muted-foreground">No expenses recorded yet.</p>
|
||||
}
|
||||
for _, exp := range expenses {
|
||||
@ExpenseListItem(spaceID, exp, methods)
|
||||
@ExpenseListItem(spaceID, exp, methods, tags)
|
||||
}
|
||||
</div>
|
||||
if totalPages > 1 {
|
||||
|
|
@ -109,7 +109,7 @@ templ ExpensesListContent(spaceID string, expenses []*model.ExpenseWithTagsAndMe
|
|||
}
|
||||
}
|
||||
|
||||
templ ExpenseListItem(spaceID string, exp *model.ExpenseWithTagsAndMethod, methods []*model.PaymentMethod) {
|
||||
templ ExpenseListItem(spaceID string, exp *model.ExpenseWithTagsAndMethod, methods []*model.PaymentMethod, tags []*model.Tag) {
|
||||
<div id={ "expense-" + exp.ID } class="p-4 flex justify-between items-start gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-1.5">
|
||||
|
|
@ -166,7 +166,7 @@ templ ExpenseListItem(spaceID string, exp *model.ExpenseWithTagsAndMethod, metho
|
|||
Update the details of this transaction.
|
||||
}
|
||||
}
|
||||
@expense.EditExpenseForm(spaceID, exp, methods)
|
||||
@expense.EditExpenseForm(spaceID, exp, methods, tags)
|
||||
}
|
||||
}
|
||||
// Delete button
|
||||
|
|
@ -208,12 +208,12 @@ templ ExpenseListItem(spaceID string, exp *model.ExpenseWithTagsAndMethod, metho
|
|||
</div>
|
||||
}
|
||||
|
||||
templ ExpenseCreatedResponse(spaceID string, expenses []*model.ExpenseWithTagsAndMethod, balance int, allocated int, currentPage, totalPages int) {
|
||||
@ExpensesListContent(spaceID, expenses, nil, currentPage, totalPages)
|
||||
templ ExpenseCreatedResponse(spaceID string, expenses []*model.ExpenseWithTagsAndMethod, balance int, allocated int, tags []*model.Tag, currentPage, totalPages int) {
|
||||
@ExpensesListContent(spaceID, expenses, nil, tags, currentPage, totalPages)
|
||||
@expense.BalanceCard(spaceID, balance, allocated, true)
|
||||
}
|
||||
|
||||
templ ExpenseUpdatedResponse(spaceID string, exp *model.ExpenseWithTagsAndMethod, balance int, allocated int, methods []*model.PaymentMethod) {
|
||||
@ExpenseListItem(spaceID, exp, methods)
|
||||
templ ExpenseUpdatedResponse(spaceID string, exp *model.ExpenseWithTagsAndMethod, balance int, allocated int, methods []*model.PaymentMethod, tags []*model.Tag) {
|
||||
@ExpenseListItem(spaceID, exp, methods, tags)
|
||||
@expense.BalanceCard(exp.SpaceID, balance, allocated, true)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ templ SpaceRecurringPage(space *model.Space, recs []*model.RecurringExpenseWithT
|
|||
<p class="p-4 text-sm text-muted-foreground">No recurring transactions set up yet.</p>
|
||||
}
|
||||
for _, re := range recs {
|
||||
@recurring.RecurringItem(space.ID, re, methods)
|
||||
@recurring.RecurringItem(space.ID, re, methods, tags)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue