fix: remove sse #7
17 changed files with 1118 additions and 35 deletions
Merge branch 'main' into fix/sse
commit
9044a64b10
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -59,3 +59,4 @@ tmp/
|
||||||
*.db-wal
|
*.db-wal
|
||||||
|
|
||||||
output.css
|
output.css
|
||||||
|
.session
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
version: "3"
|
version: "3"
|
||||||
vars:
|
vars:
|
||||||
TEMPL_PROXYBIND: '{{.TEMPL_PROXYBIND | default "127.0.0.1"}}'
|
TEMPL_PROXYBIND: '{{.TEMPL_PROXYBIND | default "127.0.0.1"}}'
|
||||||
|
TEMPL_PROXYPORT: '{{.TEMPL_PROXYPORT | default "7331"}}'
|
||||||
TEMPL_PROXY: '{{.TEMPL_PROXY | default "http://127.0.0.1:9000"}}'
|
TEMPL_PROXY: '{{.TEMPL_PROXY | default "http://127.0.0.1:9000"}}'
|
||||||
tasks:
|
tasks:
|
||||||
# Development Tools
|
# Development Tools
|
||||||
templ:
|
templ:
|
||||||
desc: Run templ with integrated server and hot reload
|
desc: Run templ with integrated server and hot reload
|
||||||
cmds:
|
cmds:
|
||||||
- go tool templ generate --watch --cmd="go run ./cmd/server/main.go" --proxybind="{{.TEMPL_PROXYBIND}}" --proxy="{{.TEMPL_PROXY}}" --open-browser=false
|
- go tool templ generate --watch --cmd="go run ./cmd/server/main.go" --proxybind="{{.TEMPL_PROXYBIND}}" --proxyport="{{.TEMPL_PROXYPORT}}" --proxy="{{.TEMPL_PROXY}}" --open-browser=false
|
||||||
tailwind-clean:
|
tailwind-clean:
|
||||||
desc: Clean tailwind output
|
desc: Clean tailwind output
|
||||||
cmds:
|
cmds:
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package handler
|
||||||
import (
|
import (
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"git.juancwu.dev/juancwu/budgit/internal/ctxkeys"
|
"git.juancwu.dev/juancwu/budgit/internal/ctxkeys"
|
||||||
"git.juancwu.dev/juancwu/budgit/internal/service"
|
"git.juancwu.dev/juancwu/budgit/internal/service"
|
||||||
|
|
@ -43,3 +44,23 @@ func (h *dashboardHandler) DashboardPage(w http.ResponseWriter, r *http.Request)
|
||||||
|
|
||||||
ui.Render(w, r, pages.Dashboard(spaces, totalBalance))
|
ui.Render(w, r, pages.Dashboard(spaces, totalBalance))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *dashboardHandler) CreateSpace(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := ctxkeys.User(r.Context())
|
||||||
|
|
||||||
|
name := strings.TrimSpace(r.FormValue("name"))
|
||||||
|
if name == "" {
|
||||||
|
http.Error(w, "Space name is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
space, err := h.spaceService.CreateSpace(name, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to create space", "error", err, "user_id", user.ID)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("HX-Redirect", "/app/spaces/"+space.ID)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,20 @@ func NewSpaceHandler(ss *service.SpaceService, ts *service.TagService, sls *serv
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getExpenseForSpace fetches an expense and verifies it belongs to the given space.
|
||||||
|
func (h *SpaceHandler) getExpenseForSpace(w http.ResponseWriter, spaceID, expenseID string) *model.Expense {
|
||||||
|
exp, err := h.expenseService.GetExpense(expenseID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Expense not found", http.StatusNotFound)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if exp.SpaceID != spaceID {
|
||||||
|
http.Error(w, "Not Found", http.StatusNotFound)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return exp
|
||||||
|
}
|
||||||
|
|
||||||
// getListForSpace fetches a shopping list and verifies it belongs to the given space.
|
// getListForSpace fetches a shopping list and verifies it belongs to the given space.
|
||||||
// Returns the list on success, or writes an error response and returns nil.
|
// Returns the list on success, or writes an error response and returns nil.
|
||||||
func (h *SpaceHandler) getListForSpace(w http.ResponseWriter, spaceID, listID string) *model.ShoppingList {
|
func (h *SpaceHandler) getListForSpace(w http.ResponseWriter, spaceID, listID string) *model.ShoppingList {
|
||||||
|
|
@ -368,7 +382,7 @@ func (h *SpaceHandler) ExpensesPage(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
expenses, err := h.expenseService.GetExpensesForSpace(spaceID)
|
expenses, err := h.expenseService.GetExpensesWithTagsForSpace(spaceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to get expenses for space", "error", err, "space_id", spaceID)
|
slog.Error("failed to get expenses for space", "error", err, "space_id", spaceID)
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
|
@ -542,7 +556,146 @@ func (h *SpaceHandler) CreateExpense(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.Render(w, r, pages.ExpenseCreatedResponse(newExpense, balance))
|
// Build tags for the newly created expense
|
||||||
|
tagsMap, _ := h.expenseService.GetTagsByExpenseIDs([]string{newExpense.ID})
|
||||||
|
newExpenseWithTags := &model.ExpenseWithTags{
|
||||||
|
Expense: *newExpense,
|
||||||
|
Tags: tagsMap[newExpense.ID],
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.Render(w, r, pages.ExpenseCreatedResponse(spaceID, newExpenseWithTags, balance))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SpaceHandler) UpdateExpense(w http.ResponseWriter, r *http.Request) {
|
||||||
|
spaceID := r.PathValue("spaceID")
|
||||||
|
expenseID := r.PathValue("expenseID")
|
||||||
|
|
||||||
|
if h.getExpenseForSpace(w, spaceID, expenseID) == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
description := r.FormValue("description")
|
||||||
|
amountStr := r.FormValue("amount")
|
||||||
|
typeStr := r.FormValue("type")
|
||||||
|
dateStr := r.FormValue("date")
|
||||||
|
tagNames := r.Form["tags"]
|
||||||
|
|
||||||
|
if description == "" || amountStr == "" || typeStr == "" || dateStr == "" {
|
||||||
|
http.Error(w, "All fields are required.", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
amountFloat, err := strconv.ParseFloat(amountStr, 64)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid amount format.", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
amountCents := int(amountFloat * 100)
|
||||||
|
|
||||||
|
date, err := time.Parse("2006-01-02", dateStr)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid date format.", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
expenseType := model.ExpenseType(typeStr)
|
||||||
|
if expenseType != model.ExpenseTypeExpense && expenseType != model.ExpenseTypeTopup {
|
||||||
|
http.Error(w, "Invalid transaction type.", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tag processing (same as CreateExpense)
|
||||||
|
existingTags, err := h.tagService.GetTagsForSpace(spaceID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to get tags for space", "error", err, "space_id", spaceID)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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 == "" || 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 from expense form", "error", err, "tag_name", tagName)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
finalTagIDs = append(finalTagIDs, newTag.ID)
|
||||||
|
existingTagsMap[tagName] = newTag.ID
|
||||||
|
}
|
||||||
|
processedTags[tagName] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
dto := service.UpdateExpenseDTO{
|
||||||
|
ID: expenseID,
|
||||||
|
SpaceID: spaceID,
|
||||||
|
Description: description,
|
||||||
|
Amount: amountCents,
|
||||||
|
Type: expenseType,
|
||||||
|
Date: date,
|
||||||
|
TagIDs: finalTagIDs,
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedExpense, err := h.expenseService.UpdateExpense(dto)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to update expense", "error", err)
|
||||||
|
http.Error(w, "Failed to update expense.", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tagsMap, _ := h.expenseService.GetTagsByExpenseIDs([]string{updatedExpense.ID})
|
||||||
|
expWithTags := &model.ExpenseWithTags{
|
||||||
|
Expense: *updatedExpense,
|
||||||
|
Tags: tagsMap[updatedExpense.ID],
|
||||||
|
}
|
||||||
|
|
||||||
|
balance, err := h.expenseService.GetBalanceForSpace(spaceID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to get balance after update", "error", err, "space_id", spaceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.Render(w, r, pages.ExpenseUpdatedResponse(spaceID, expWithTags, balance))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SpaceHandler) DeleteExpense(w http.ResponseWriter, r *http.Request) {
|
||||||
|
spaceID := r.PathValue("spaceID")
|
||||||
|
expenseID := r.PathValue("expenseID")
|
||||||
|
|
||||||
|
if h.getExpenseForSpace(w, spaceID, expenseID) == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.expenseService.DeleteExpense(expenseID, spaceID); err != nil {
|
||||||
|
slog.Error("failed to delete expense", "error", err, "expense_id", expenseID)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
balance, err := h.expenseService.GetBalanceForSpace(spaceID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to get balance after delete", "error", err, "space_id", spaceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.Render(w, r, expense.BalanceCard(spaceID, balance, true))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *SpaceHandler) CreateInvite(w http.ResponseWriter, r *http.Request) {
|
func (h *SpaceHandler) CreateInvite(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
@ -621,14 +774,14 @@ func (h *SpaceHandler) GetBalanceCard(w http.ResponseWriter, r *http.Request) {
|
||||||
func (h *SpaceHandler) GetExpensesList(w http.ResponseWriter, r *http.Request) {
|
func (h *SpaceHandler) GetExpensesList(w http.ResponseWriter, r *http.Request) {
|
||||||
spaceID := r.PathValue("spaceID")
|
spaceID := r.PathValue("spaceID")
|
||||||
|
|
||||||
expenses, err := h.expenseService.GetExpensesForSpace(spaceID)
|
expenses, err := h.expenseService.GetExpensesWithTagsForSpace(spaceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to get expenses", "error", err, "space_id", spaceID)
|
slog.Error("failed to get expenses", "error", err, "space_id", spaceID)
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.Render(w, r, pages.ExpensesListContent(expenses))
|
ui.Render(w, r, pages.ExpensesListContent(spaceID, expenses))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *SpaceHandler) GetShoppingListItems(w http.ResponseWriter, r *http.Request) {
|
func (h *SpaceHandler) GetShoppingListItems(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
@ -685,6 +838,155 @@ func (h *SpaceHandler) GetListCardItems(w http.ResponseWriter, r *http.Request)
|
||||||
ui.Render(w, r, shoppinglist.ListCardItems(spaceID, listID, items, page, totalPages))
|
ui.Render(w, r, shoppinglist.ListCardItems(spaceID, listID, items, page, totalPages))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *SpaceHandler) SettingsPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
spaceID := r.PathValue("spaceID")
|
||||||
|
user := ctxkeys.User(r.Context())
|
||||||
|
|
||||||
|
space, err := h.spaceService.GetSpace(spaceID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to get space", "error", err, "space_id", spaceID)
|
||||||
|
http.Error(w, "Space not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
members, err := h.spaceService.GetMembers(spaceID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to get members", "error", err, "space_id", spaceID)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isOwner := space.OwnerID == user.ID
|
||||||
|
|
||||||
|
var pendingInvites []*model.SpaceInvitation
|
||||||
|
if isOwner {
|
||||||
|
pendingInvites, err = h.inviteService.GetPendingInvites(spaceID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to get pending invites", "error", err, "space_id", spaceID)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.Render(w, r, pages.SpaceSettingsPage(space, members, pendingInvites, isOwner, user.ID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SpaceHandler) UpdateSpaceName(w http.ResponseWriter, r *http.Request) {
|
||||||
|
spaceID := r.PathValue("spaceID")
|
||||||
|
user := ctxkeys.User(r.Context())
|
||||||
|
|
||||||
|
space, err := h.spaceService.GetSpace(spaceID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Space not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if space.OwnerID != user.ID {
|
||||||
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
name := r.FormValue("name")
|
||||||
|
if name == "" {
|
||||||
|
http.Error(w, "Name is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.spaceService.UpdateSpaceName(spaceID, name); err != nil {
|
||||||
|
slog.Error("failed to update space name", "error", err, "space_id", spaceID)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("HX-Refresh", "true")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SpaceHandler) RemoveMember(w http.ResponseWriter, r *http.Request) {
|
||||||
|
spaceID := r.PathValue("spaceID")
|
||||||
|
userID := r.PathValue("userID")
|
||||||
|
user := ctxkeys.User(r.Context())
|
||||||
|
|
||||||
|
space, err := h.spaceService.GetSpace(spaceID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Space not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if space.OwnerID != user.ID {
|
||||||
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if userID == user.ID {
|
||||||
|
http.Error(w, "Cannot remove yourself", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.spaceService.RemoveMember(spaceID, userID); err != nil {
|
||||||
|
slog.Error("failed to remove member", "error", err, "space_id", spaceID, "user_id", userID)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SpaceHandler) CancelInvite(w http.ResponseWriter, r *http.Request) {
|
||||||
|
spaceID := r.PathValue("spaceID")
|
||||||
|
token := r.PathValue("token")
|
||||||
|
user := ctxkeys.User(r.Context())
|
||||||
|
|
||||||
|
space, err := h.spaceService.GetSpace(spaceID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Space not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if space.OwnerID != user.ID {
|
||||||
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.inviteService.CancelInvite(token); err != nil {
|
||||||
|
slog.Error("failed to cancel invite", "error", err, "space_id", spaceID, "token", token)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SpaceHandler) GetPendingInvites(w http.ResponseWriter, r *http.Request) {
|
||||||
|
spaceID := r.PathValue("spaceID")
|
||||||
|
user := ctxkeys.User(r.Context())
|
||||||
|
|
||||||
|
space, err := h.spaceService.GetSpace(spaceID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Space not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if space.OwnerID != user.ID {
|
||||||
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingInvites, err := h.inviteService.GetPendingInvites(spaceID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to get pending invites", "error", err, "space_id", spaceID)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.Render(w, r, pages.PendingInvitesList(spaceID, pendingInvites))
|
||||||
|
}
|
||||||
|
|
||||||
func (h *SpaceHandler) buildListCards(spaceID string) ([]model.ListCardData, error) {
|
func (h *SpaceHandler) buildListCards(spaceID string) ([]model.ListCardData, error) {
|
||||||
lists, err := h.listService.GetListsForSpace(spaceID)
|
lists, err := h.listService.GetListsForSpace(spaceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,11 @@ type Expense struct {
|
||||||
UpdatedAt time.Time `db:"updated_at"`
|
UpdatedAt time.Time `db:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ExpenseWithTags struct {
|
||||||
|
Expense
|
||||||
|
Tags []*Tag
|
||||||
|
}
|
||||||
|
|
||||||
type ExpenseTag struct {
|
type ExpenseTag struct {
|
||||||
ExpenseID string `db:"expense_id"`
|
ExpenseID string `db:"expense_id"`
|
||||||
TagID string `db:"tag_id"`
|
TagID string `db:"tag_id"`
|
||||||
|
|
|
||||||
|
|
@ -23,3 +23,12 @@ type SpaceMember struct {
|
||||||
Role Role `db:"role"`
|
Role Role `db:"role"`
|
||||||
JoinedAt time.Time `db:"joined_at"`
|
JoinedAt time.Time `db:"joined_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SpaceMemberWithProfile struct {
|
||||||
|
SpaceID string `db:"space_id"`
|
||||||
|
UserID string `db:"user_id"`
|
||||||
|
Role Role `db:"role"`
|
||||||
|
JoinedAt time.Time `db:"joined_at"`
|
||||||
|
Name string `db:"name"`
|
||||||
|
Email string `db:"email"`
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,9 @@ type ExpenseRepository interface {
|
||||||
GetByID(id string) (*model.Expense, error)
|
GetByID(id string) (*model.Expense, error)
|
||||||
GetBySpaceID(spaceID string) ([]*model.Expense, error)
|
GetBySpaceID(spaceID string) ([]*model.Expense, error)
|
||||||
GetExpensesByTag(spaceID string, fromDate, toDate time.Time) ([]*model.TagExpenseSummary, error)
|
GetExpensesByTag(spaceID string, fromDate, toDate time.Time) ([]*model.TagExpenseSummary, error)
|
||||||
|
GetTagsByExpenseIDs(expenseIDs []string) (map[string][]*model.Tag, error)
|
||||||
|
Update(expense *model.Expense, tagIDs []string) error
|
||||||
|
Delete(id string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type expenseRepository struct {
|
type expenseRepository struct {
|
||||||
|
|
@ -91,10 +94,10 @@ func (r *expenseRepository) GetBySpaceID(spaceID string) ([]*model.Expense, erro
|
||||||
func (r *expenseRepository) GetExpensesByTag(spaceID string, fromDate, toDate time.Time) ([]*model.TagExpenseSummary, error) {
|
func (r *expenseRepository) GetExpensesByTag(spaceID string, fromDate, toDate time.Time) ([]*model.TagExpenseSummary, error) {
|
||||||
var summaries []*model.TagExpenseSummary
|
var summaries []*model.TagExpenseSummary
|
||||||
query := `
|
query := `
|
||||||
SELECT
|
SELECT
|
||||||
t.id as tag_id,
|
t.id as tag_id,
|
||||||
t.name as tag_name,
|
t.name as tag_name,
|
||||||
t.color as tag_color,
|
t.color as tag_color,
|
||||||
SUM(e.amount_cents) as total_amount
|
SUM(e.amount_cents) as total_amount
|
||||||
FROM expenses e
|
FROM expenses e
|
||||||
JOIN expense_tags et ON e.id = et.expense_id
|
JOIN expense_tags et ON e.id = et.expense_id
|
||||||
|
|
@ -109,3 +112,81 @@ func (r *expenseRepository) GetExpensesByTag(spaceID string, fromDate, toDate ti
|
||||||
}
|
}
|
||||||
return summaries, nil
|
return summaries, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *expenseRepository) GetTagsByExpenseIDs(expenseIDs []string) (map[string][]*model.Tag, error) {
|
||||||
|
if len(expenseIDs) == 0 {
|
||||||
|
return make(map[string][]*model.Tag), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type row struct {
|
||||||
|
ExpenseID string `db:"expense_id"`
|
||||||
|
ID string `db:"id"`
|
||||||
|
SpaceID string `db:"space_id"`
|
||||||
|
Name string `db:"name"`
|
||||||
|
Color *string `db:"color"`
|
||||||
|
}
|
||||||
|
|
||||||
|
query, args, err := sqlx.In(`
|
||||||
|
SELECT et.expense_id, t.id, t.space_id, t.name, t.color
|
||||||
|
FROM expense_tags et
|
||||||
|
JOIN tags t ON et.tag_id = t.id
|
||||||
|
WHERE et.expense_id IN (?)
|
||||||
|
ORDER BY t.name;
|
||||||
|
`, expenseIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
query = r.db.Rebind(query)
|
||||||
|
|
||||||
|
var rows []row
|
||||||
|
if err := r.db.Select(&rows, query, args...); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make(map[string][]*model.Tag)
|
||||||
|
for _, rw := range rows {
|
||||||
|
result[rw.ExpenseID] = append(result[rw.ExpenseID], &model.Tag{
|
||||||
|
ID: rw.ID,
|
||||||
|
SpaceID: rw.SpaceID,
|
||||||
|
Name: rw.Name,
|
||||||
|
Color: rw.Color,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *expenseRepository) Update(expense *model.Expense, tagIDs []string) error {
|
||||||
|
tx, err := r.db.Beginx()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
query := `UPDATE expenses SET description = $1, amount_cents = $2, type = $3, date = $4, updated_at = $5 WHERE id = $6;`
|
||||||
|
_, err = tx.Exec(query, expense.Description, expense.AmountCents, expense.Type, expense.Date, expense.UpdatedAt, expense.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace tags: delete all existing, re-insert
|
||||||
|
_, err = tx.Exec(`DELETE FROM expense_tags WHERE expense_id = $1;`, expense.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tagIDs) > 0 {
|
||||||
|
insertTag := `INSERT INTO expense_tags (expense_id, tag_id) VALUES ($1, $2);`
|
||||||
|
for _, tagID := range tagIDs {
|
||||||
|
if _, err := tx.Exec(insertTag, expense.ID, tagID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *expenseRepository) Delete(id string) error {
|
||||||
|
_, err := r.db.Exec(`DELETE FROM expenses WHERE id = $1;`, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ type SpaceRepository interface {
|
||||||
AddMember(spaceID, userID string, role model.Role) error
|
AddMember(spaceID, userID string, role model.Role) error
|
||||||
RemoveMember(spaceID, userID string) error
|
RemoveMember(spaceID, userID string) error
|
||||||
IsMember(spaceID, userID string) (bool, error)
|
IsMember(spaceID, userID string) (bool, error)
|
||||||
|
GetMembers(spaceID string) ([]*model.SpaceMemberWithProfile, error)
|
||||||
|
UpdateName(spaceID, name string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type spaceRepository struct {
|
type spaceRepository struct {
|
||||||
|
|
@ -106,3 +108,23 @@ func (r *spaceRepository) IsMember(spaceID, userID string) (bool, error) {
|
||||||
}
|
}
|
||||||
return count > 0, nil
|
return count > 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *spaceRepository) GetMembers(spaceID string) ([]*model.SpaceMemberWithProfile, error) {
|
||||||
|
var members []*model.SpaceMemberWithProfile
|
||||||
|
query := `
|
||||||
|
SELECT sm.space_id, sm.user_id, sm.role, sm.joined_at,
|
||||||
|
p.name, u.email
|
||||||
|
FROM space_members sm
|
||||||
|
JOIN users u ON sm.user_id = u.id
|
||||||
|
JOIN profiles p ON sm.user_id = p.user_id
|
||||||
|
WHERE sm.space_id = $1
|
||||||
|
ORDER BY sm.role DESC, sm.joined_at ASC;`
|
||||||
|
err := r.db.Select(&members, query, spaceID)
|
||||||
|
return members, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *spaceRepository) UpdateName(spaceID, name string) error {
|
||||||
|
query := `UPDATE spaces SET name = $1, updated_at = $2 WHERE id = $3;`
|
||||||
|
_, err := r.db.Exec(query, name, time.Now(), spaceID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ func SetupRoutes(a *app.App) http.Handler {
|
||||||
mux.HandleFunc("POST /auth/onboarding", authRateLimiter(middleware.RequireAuth(auth.CompleteOnboarding)))
|
mux.HandleFunc("POST /auth/onboarding", authRateLimiter(middleware.RequireAuth(auth.CompleteOnboarding)))
|
||||||
|
|
||||||
mux.HandleFunc("GET /app/dashboard", middleware.RequireAuth(dashboard.DashboardPage))
|
mux.HandleFunc("GET /app/dashboard", middleware.RequireAuth(dashboard.DashboardPage))
|
||||||
|
mux.HandleFunc("POST /app/spaces", middleware.RequireAuth(dashboard.CreateSpace))
|
||||||
mux.HandleFunc("GET /app/settings", middleware.RequireAuth(settings.SettingsPage))
|
mux.HandleFunc("GET /app/settings", middleware.RequireAuth(settings.SettingsPage))
|
||||||
mux.HandleFunc("POST /app/settings/password", authRateLimiter(middleware.RequireAuth(settings.SetPassword)))
|
mux.HandleFunc("POST /app/settings/password", authRateLimiter(middleware.RequireAuth(settings.SetPassword)))
|
||||||
|
|
||||||
|
|
@ -117,6 +118,14 @@ func SetupRoutes(a *app.App) http.Handler {
|
||||||
createExpenseWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(createExpenseHandler)
|
createExpenseWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(createExpenseHandler)
|
||||||
mux.Handle("POST /app/spaces/{spaceID}/expenses", createExpenseWithAccess)
|
mux.Handle("POST /app/spaces/{spaceID}/expenses", createExpenseWithAccess)
|
||||||
|
|
||||||
|
updateExpenseHandler := middleware.RequireAuth(space.UpdateExpense)
|
||||||
|
updateExpenseWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(updateExpenseHandler)
|
||||||
|
mux.Handle("PATCH /app/spaces/{spaceID}/expenses/{expenseID}", updateExpenseWithAccess)
|
||||||
|
|
||||||
|
deleteExpenseHandler := middleware.RequireAuth(space.DeleteExpense)
|
||||||
|
deleteExpenseWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(deleteExpenseHandler)
|
||||||
|
mux.Handle("DELETE /app/spaces/{spaceID}/expenses/{expenseID}", deleteExpenseWithAccess)
|
||||||
|
|
||||||
// Component routes (HTMX updates)
|
// Component routes (HTMX updates)
|
||||||
balanceCardHandler := middleware.RequireAuth(space.GetBalanceCard)
|
balanceCardHandler := middleware.RequireAuth(space.GetBalanceCard)
|
||||||
balanceCardWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(balanceCardHandler)
|
balanceCardWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(balanceCardHandler)
|
||||||
|
|
@ -138,6 +147,27 @@ func SetupRoutes(a *app.App) http.Handler {
|
||||||
listsComponentWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(listsComponentHandler)
|
listsComponentWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(listsComponentHandler)
|
||||||
mux.Handle("GET /app/spaces/{spaceID}/components/lists", listsComponentWithAccess)
|
mux.Handle("GET /app/spaces/{spaceID}/components/lists", listsComponentWithAccess)
|
||||||
|
|
||||||
|
// Settings routes
|
||||||
|
settingsPageHandler := middleware.RequireAuth(space.SettingsPage)
|
||||||
|
settingsPageWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(settingsPageHandler)
|
||||||
|
mux.Handle("GET /app/spaces/{spaceID}/settings", settingsPageWithAccess)
|
||||||
|
|
||||||
|
updateSpaceNameHandler := middleware.RequireAuth(space.UpdateSpaceName)
|
||||||
|
updateSpaceNameWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(updateSpaceNameHandler)
|
||||||
|
mux.Handle("PATCH /app/spaces/{spaceID}/settings/name", updateSpaceNameWithAccess)
|
||||||
|
|
||||||
|
removeMemberHandler := middleware.RequireAuth(space.RemoveMember)
|
||||||
|
removeMemberWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(removeMemberHandler)
|
||||||
|
mux.Handle("DELETE /app/spaces/{spaceID}/members/{userID}", removeMemberWithAccess)
|
||||||
|
|
||||||
|
cancelInviteHandler := middleware.RequireAuth(space.CancelInvite)
|
||||||
|
cancelInviteWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(cancelInviteHandler)
|
||||||
|
mux.Handle("DELETE /app/spaces/{spaceID}/invites/{token}", cancelInviteWithAccess)
|
||||||
|
|
||||||
|
getPendingInvitesHandler := middleware.RequireAuth(space.GetPendingInvites)
|
||||||
|
getPendingInvitesWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(getPendingInvitesHandler)
|
||||||
|
mux.Handle("GET /app/spaces/{spaceID}/settings/invites", getPendingInvitesWithAccess)
|
||||||
|
|
||||||
// Invite routes
|
// Invite routes
|
||||||
createInviteHandler := middleware.RequireAuth(space.CreateInvite)
|
createInviteHandler := middleware.RequireAuth(space.CreateInvite)
|
||||||
createInviteWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(createInviteHandler)
|
createInviteWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(createInviteHandler)
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,16 @@ type CreateExpenseDTO struct {
|
||||||
ItemIDs []string
|
ItemIDs []string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UpdateExpenseDTO struct {
|
||||||
|
ID string
|
||||||
|
SpaceID string
|
||||||
|
Description string
|
||||||
|
Amount int
|
||||||
|
Type model.ExpenseType
|
||||||
|
Date time.Time
|
||||||
|
TagIDs []string
|
||||||
|
}
|
||||||
|
|
||||||
type ExpenseService struct {
|
type ExpenseService struct {
|
||||||
expenseRepo repository.ExpenseRepository
|
expenseRepo repository.ExpenseRepository
|
||||||
}
|
}
|
||||||
|
|
@ -84,3 +94,83 @@ func (s *ExpenseService) GetBalanceForSpace(spaceID string) (int, error) {
|
||||||
func (s *ExpenseService) GetExpensesByTag(spaceID string, fromDate, toDate time.Time) ([]*model.TagExpenseSummary, error) {
|
func (s *ExpenseService) GetExpensesByTag(spaceID string, fromDate, toDate time.Time) ([]*model.TagExpenseSummary, error) {
|
||||||
return s.expenseRepo.GetExpensesByTag(spaceID, fromDate, toDate)
|
return s.expenseRepo.GetExpensesByTag(spaceID, fromDate, toDate)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *ExpenseService) GetExpensesWithTagsForSpace(spaceID string) ([]*model.ExpenseWithTags, error) {
|
||||||
|
expenses, err := s.expenseRepo.GetBySpaceID(spaceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ids := make([]string, len(expenses))
|
||||||
|
for i, e := range expenses {
|
||||||
|
ids[i] = e.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
tagsMap, err := s.expenseRepo.GetTagsByExpenseIDs(ids)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]*model.ExpenseWithTags, len(expenses))
|
||||||
|
for i, e := range expenses {
|
||||||
|
result[i] = &model.ExpenseWithTags{
|
||||||
|
Expense: *e,
|
||||||
|
Tags: tagsMap[e.ID],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ExpenseService) GetExpense(id string) (*model.Expense, error) {
|
||||||
|
return s.expenseRepo.GetByID(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ExpenseService) GetTagsByExpenseIDs(expenseIDs []string) (map[string][]*model.Tag, error) {
|
||||||
|
return s.expenseRepo.GetTagsByExpenseIDs(expenseIDs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ExpenseService) UpdateExpense(dto UpdateExpenseDTO) (*model.Expense, error) {
|
||||||
|
if dto.Description == "" {
|
||||||
|
return nil, fmt.Errorf("expense description cannot be empty")
|
||||||
|
}
|
||||||
|
if dto.Amount <= 0 {
|
||||||
|
return nil, fmt.Errorf("amount must be positive")
|
||||||
|
}
|
||||||
|
|
||||||
|
existing, err := s.expenseRepo.GetByID(dto.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
existing.Description = dto.Description
|
||||||
|
existing.AmountCents = dto.Amount
|
||||||
|
existing.Type = dto.Type
|
||||||
|
existing.Date = dto.Date
|
||||||
|
existing.UpdatedAt = time.Now()
|
||||||
|
|
||||||
|
if err := s.expenseRepo.Update(existing, dto.TagIDs); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
balance, _ := s.GetBalanceForSpace(dto.SpaceID)
|
||||||
|
s.eventBus.Publish(dto.SpaceID, "balance_changed", map[string]interface{}{
|
||||||
|
"balance": balance,
|
||||||
|
})
|
||||||
|
s.eventBus.Publish(dto.SpaceID, "expenses_updated", nil)
|
||||||
|
|
||||||
|
return existing, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ExpenseService) DeleteExpense(id string, spaceID string) error {
|
||||||
|
if err := s.expenseRepo.Delete(id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
balance, _ := s.GetBalanceForSpace(spaceID)
|
||||||
|
s.eventBus.Publish(spaceID, "balance_changed", map[string]interface{}{
|
||||||
|
"balance": balance,
|
||||||
|
})
|
||||||
|
s.eventBus.Publish(spaceID, "expenses_updated", nil)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,19 @@ func (s *InviteService) AcceptInvite(token, userID string) (string, error) {
|
||||||
return invite.SpaceID, s.inviteRepo.UpdateStatus(token, model.InvitationStatusAccepted)
|
return invite.SpaceID, s.inviteRepo.UpdateStatus(token, model.InvitationStatusAccepted)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *InviteService) CancelInvite(token string) error {
|
||||||
|
invite, err := s.inviteRepo.GetByToken(token)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if invite.Status != model.InvitationStatusPending {
|
||||||
|
return errors.New("invitation is not pending")
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.inviteRepo.Delete(token)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *InviteService) GetPendingInvites(spaceID string) ([]*model.SpaceInvitation, error) {
|
func (s *InviteService) GetPendingInvites(spaceID string) ([]*model.SpaceInvitation, error) {
|
||||||
// Filter for pending only in memory or repo?
|
// Filter for pending only in memory or repo?
|
||||||
// Repo returns all.
|
// Repo returns all.
|
||||||
|
|
|
||||||
|
|
@ -88,3 +88,25 @@ func (s *SpaceService) IsMember(userID, spaceID string) (bool, error) {
|
||||||
}
|
}
|
||||||
return isMember, nil
|
return isMember, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetMembers returns all members of a space with their profile info.
|
||||||
|
func (s *SpaceService) GetMembers(spaceID string) ([]*model.SpaceMemberWithProfile, error) {
|
||||||
|
members, err := s.spaceRepo.GetMembers(spaceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get members: %w", err)
|
||||||
|
}
|
||||||
|
return members, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveMember removes a member from a space.
|
||||||
|
func (s *SpaceService) RemoveMember(spaceID, userID string) error {
|
||||||
|
return s.spaceRepo.RemoveMember(spaceID, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSpaceName updates the name of a space.
|
||||||
|
func (s *SpaceService) UpdateSpaceName(spaceID, name string) error {
|
||||||
|
if name == "" {
|
||||||
|
return fmt.Errorf("space name cannot be empty")
|
||||||
|
}
|
||||||
|
return s.spaceRepo.UpdateName(spaceID, name)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -232,6 +232,106 @@ templ AddExpenseForm(props AddExpenseFormProps) {
|
||||||
</form>
|
</form>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
templ EditExpenseForm(spaceID string, exp *model.ExpenseWithTags) {
|
||||||
|
{{ editDialogID := "edit-expense-" + exp.ID }}
|
||||||
|
{{ tagValues := make([]string, len(exp.Tags)) }}
|
||||||
|
for i, t := range exp.Tags {
|
||||||
|
{{ tagValues[i] = t.Name }}
|
||||||
|
}
|
||||||
|
<form
|
||||||
|
hx-patch={ fmt.Sprintf("/app/spaces/%s/expenses/%s", spaceID, exp.ID) }
|
||||||
|
hx-target={ "#expense-" + exp.ID }
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
_={ "on htmx:afterOnLoad if event.detail.xhr.status == 200 then call window.tui.dialog.close('" + editDialogID + "') end" }
|
||||||
|
class="space-y-4"
|
||||||
|
>
|
||||||
|
@csrf.Token()
|
||||||
|
// Type
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
@radio.Radio(radio.Props{
|
||||||
|
ID: "edit-type-expense-" + exp.ID,
|
||||||
|
Name: "type",
|
||||||
|
Value: "expense",
|
||||||
|
Checked: exp.Type == model.ExpenseTypeExpense,
|
||||||
|
})
|
||||||
|
<div class="grid gap-2">
|
||||||
|
@label.Label(label.Props{For: "edit-type-expense-" + exp.ID}) {
|
||||||
|
Expense
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
@radio.Radio(radio.Props{
|
||||||
|
ID: "edit-type-topup-" + exp.ID,
|
||||||
|
Name: "type",
|
||||||
|
Value: "topup",
|
||||||
|
Checked: exp.Type == model.ExpenseTypeTopup,
|
||||||
|
})
|
||||||
|
<div class="grid gap-2">
|
||||||
|
@label.Label(label.Props{For: "edit-type-topup-" + exp.ID}) {
|
||||||
|
Top-up
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
// Description
|
||||||
|
<div>
|
||||||
|
@label.Label(label.Props{For: "edit-description-" + exp.ID}) {
|
||||||
|
Description
|
||||||
|
}
|
||||||
|
@input.Input(input.Props{
|
||||||
|
Name: "description",
|
||||||
|
ID: "edit-description-" + exp.ID,
|
||||||
|
Value: exp.Description,
|
||||||
|
Attributes: templ.Attributes{"required": "true"},
|
||||||
|
})
|
||||||
|
</div>
|
||||||
|
// Amount
|
||||||
|
<div>
|
||||||
|
@label.Label(label.Props{For: "edit-amount-" + exp.ID}) {
|
||||||
|
Amount
|
||||||
|
}
|
||||||
|
@input.Input(input.Props{
|
||||||
|
Name: "amount",
|
||||||
|
ID: "edit-amount-" + exp.ID,
|
||||||
|
Type: "number",
|
||||||
|
Value: fmt.Sprintf("%.2f", float64(exp.AmountCents)/100.0),
|
||||||
|
Attributes: templ.Attributes{"step": "0.01", "required": "true"},
|
||||||
|
})
|
||||||
|
</div>
|
||||||
|
// Date
|
||||||
|
<div>
|
||||||
|
@label.Label(label.Props{For: "edit-date-" + exp.ID}) {
|
||||||
|
Date
|
||||||
|
}
|
||||||
|
@datepicker.DatePicker(datepicker.Props{
|
||||||
|
ID: "edit-date-" + exp.ID,
|
||||||
|
Name: "date",
|
||||||
|
Value: exp.Date,
|
||||||
|
Attributes: templ.Attributes{"required": "true"},
|
||||||
|
})
|
||||||
|
</div>
|
||||||
|
// Tags
|
||||||
|
<div>
|
||||||
|
@label.Label(label.Props{For: "edit-tags-" + exp.ID}) {
|
||||||
|
Tags
|
||||||
|
}
|
||||||
|
@tagsinput.TagsInput(tagsinput.Props{
|
||||||
|
ID: "edit-tags-" + exp.ID,
|
||||||
|
Name: "tags",
|
||||||
|
Value: tagValues,
|
||||||
|
Placeholder: "Add tags (press enter)",
|
||||||
|
})
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
@button.Button(button.Props{Type: button.TypeSubmit}) {
|
||||||
|
Save
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
|
||||||
templ BalanceCard(spaceID string, balance int, oob bool) {
|
templ BalanceCard(spaceID string, balance int, oob bool) {
|
||||||
<div
|
<div
|
||||||
id="balance-card"
|
id="balance-card"
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,16 @@ templ Space(title string, space *model.Space) {
|
||||||
<span>Tags</span>
|
<span>Tags</span>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@sidebar.MenuItem() {
|
||||||
|
@sidebar.MenuButton(sidebar.MenuButtonProps{
|
||||||
|
Href: "/app/spaces/" + space.ID + "/settings",
|
||||||
|
IsActive: ctxkeys.URLPath(ctx) == "/app/spaces/"+space.ID+"/settings",
|
||||||
|
Tooltip: "Settings",
|
||||||
|
}) {
|
||||||
|
@icon.Settings(icon.Props{Class: "size-4"})
|
||||||
|
<span>Settings</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,13 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"git.juancwu.dev/juancwu/budgit/internal/model"
|
"git.juancwu.dev/juancwu/budgit/internal/model"
|
||||||
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
|
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
|
||||||
|
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
|
||||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/card"
|
"git.juancwu.dev/juancwu/budgit/internal/ui/components/card"
|
||||||
|
"git.juancwu.dev/juancwu/budgit/internal/ui/components/csrf"
|
||||||
|
"git.juancwu.dev/juancwu/budgit/internal/ui/components/dialog"
|
||||||
|
"git.juancwu.dev/juancwu/budgit/internal/ui/components/icon"
|
||||||
|
"git.juancwu.dev/juancwu/budgit/internal/ui/components/input"
|
||||||
|
"git.juancwu.dev/juancwu/budgit/internal/ui/components/label"
|
||||||
)
|
)
|
||||||
|
|
||||||
templ Dashboard(spaces []*model.Space, totalBalance int) {
|
templ Dashboard(spaces []*model.Space, totalBalance int) {
|
||||||
|
|
@ -43,11 +49,48 @@ templ Dashboard(spaces []*model.Space, totalBalance int) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Option to create a new space
|
// Option to create a new space
|
||||||
@card.Card(card.Props{ Class: "h-full border-dashed" }) {
|
@dialog.Dialog(dialog.Props{ID: "create-space-dialog"}) {
|
||||||
@card.Content(card.ContentProps{ Class: "h-full flex flex-col items-center justify-center py-12" }) {
|
@dialog.Trigger() {
|
||||||
<p class="text-muted-foreground mb-4">Need another space?</p>
|
@card.Card(card.Props{ Class: "h-full border-dashed cursor-pointer transition-colors hover:border-primary" }) {
|
||||||
// TODO: Add a button or link to create a new space
|
@card.Content(card.ContentProps{ Class: "h-full flex flex-col items-center justify-center py-12" }) {
|
||||||
<span class="text-sm font-medium opacity-50">Create Space (Coming Soon)</span>
|
@icon.Plus(icon.Props{Class: "h-8 w-8 text-muted-foreground mb-2"})
|
||||||
|
<p class="text-muted-foreground">Create a new space</p>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@dialog.Content() {
|
||||||
|
@dialog.Header() {
|
||||||
|
@dialog.Title() {
|
||||||
|
Create Space
|
||||||
|
}
|
||||||
|
@dialog.Description() {
|
||||||
|
Create a new space to organize expenses and shopping lists.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
<form hx-post="/app/spaces" hx-swap="none" class="space-y-4">
|
||||||
|
@csrf.Token()
|
||||||
|
<div class="space-y-2">
|
||||||
|
@label.Label(label.Props{For: "space-name"}) {
|
||||||
|
Name
|
||||||
|
}
|
||||||
|
@input.Input(input.Props{
|
||||||
|
ID: "space-name",
|
||||||
|
Name: "name",
|
||||||
|
Type: input.TypeText,
|
||||||
|
Placeholder: "e.g. Household, Trip, Roommates",
|
||||||
|
})
|
||||||
|
</div>
|
||||||
|
@dialog.Footer() {
|
||||||
|
@dialog.Close(dialog.CloseProps{For: "create-space-dialog"}) {
|
||||||
|
@button.Button(button.Props{Variant: button.VariantOutline, Type: button.TypeButton}) {
|
||||||
|
Cancel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@button.Button(button.Props{Type: button.TypeSubmit}) {
|
||||||
|
Create
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</form>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,15 @@ package pages
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"git.juancwu.dev/juancwu/budgit/internal/model"
|
"git.juancwu.dev/juancwu/budgit/internal/model"
|
||||||
|
"git.juancwu.dev/juancwu/budgit/internal/ui/components/badge"
|
||||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
|
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
|
||||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/dialog"
|
"git.juancwu.dev/juancwu/budgit/internal/ui/components/dialog"
|
||||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/expense"
|
"git.juancwu.dev/juancwu/budgit/internal/ui/components/expense"
|
||||||
|
"git.juancwu.dev/juancwu/budgit/internal/ui/components/icon"
|
||||||
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
|
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
|
||||||
)
|
)
|
||||||
|
|
||||||
templ SpaceExpensesPage(space *model.Space, expenses []*model.Expense, balance int, tags []*model.Tag, listsWithItems []model.ListWithUncheckedItems) {
|
templ SpaceExpensesPage(space *model.Space, expenses []*model.ExpenseWithTags, balance int, tags []*model.Tag, listsWithItems []model.ListWithUncheckedItems) {
|
||||||
@layouts.Space("Expenses", space) {
|
@layouts.Space("Expenses", space) {
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
|
|
@ -30,11 +32,11 @@ templ SpaceExpensesPage(space *model.Space, expenses []*model.Expense, balance i
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@expense.AddExpenseForm(expense.AddExpenseFormProps{
|
@expense.AddExpenseForm(expense.AddExpenseFormProps{
|
||||||
Space: space,
|
Space: space,
|
||||||
Tags: tags,
|
Tags: tags,
|
||||||
ListsWithItems: listsWithItems,
|
ListsWithItems: listsWithItems,
|
||||||
DialogID: "add-expense-dialog",
|
DialogID: "add-expense-dialog",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -42,45 +44,113 @@ templ SpaceExpensesPage(space *model.Space, expenses []*model.Expense, balance i
|
||||||
@expense.BalanceCard(space.ID, balance, false)
|
@expense.BalanceCard(space.ID, balance, false)
|
||||||
// List of expenses
|
// List of expenses
|
||||||
<div class="border rounded-lg">
|
<div class="border rounded-lg">
|
||||||
@ExpensesListContent(expenses)
|
@ExpensesListContent(space.ID, expenses)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
templ ExpensesListContent(expenses []*model.Expense) {
|
templ ExpensesListContent(spaceID string, expenses []*model.ExpenseWithTags) {
|
||||||
<h2 class="text-lg font-semibold p-4">History</h2>
|
<h2 class="text-lg font-semibold p-4">History</h2>
|
||||||
<div id="expenses-list" class="divide-y">
|
<div id="expenses-list" class="divide-y">
|
||||||
if len(expenses) == 0 {
|
if len(expenses) == 0 {
|
||||||
<p class="p-4 text-sm text-muted-foreground">No expenses recorded yet.</p>
|
<p class="p-4 text-sm text-muted-foreground">No expenses recorded yet.</p>
|
||||||
}
|
}
|
||||||
for _, expense := range expenses {
|
for _, exp := range expenses {
|
||||||
@ExpenseListItem(expense)
|
@ExpenseListItem(spaceID, exp)
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
templ ExpenseListItem(expense *model.Expense) {
|
templ ExpenseListItem(spaceID string, exp *model.ExpenseWithTags) {
|
||||||
<div class="p-4 flex justify-between items-center">
|
<div id={ "expense-" + exp.ID } class="p-4 flex justify-between items-start gap-2">
|
||||||
<div>
|
<div class="min-w-0 flex-1">
|
||||||
<p class="font-medium">{ expense.Description }</p>
|
<p class="font-medium">{ exp.Description }</p>
|
||||||
<p class="text-sm text-muted-foreground">{ expense.Date.Format("Jan 02, 2006") }</p>
|
<p class="text-sm text-muted-foreground">{ exp.Date.Format("Jan 02, 2006") }</p>
|
||||||
|
if len(exp.Tags) > 0 {
|
||||||
|
<div class="flex flex-wrap gap-1 mt-1">
|
||||||
|
for _, t := range exp.Tags {
|
||||||
|
@badge.Badge(badge.Props{Variant: badge.VariantSecondary}) {
|
||||||
|
{ t.Name }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="flex items-center gap-1 shrink-0">
|
||||||
if expense.Type == model.ExpenseTypeExpense {
|
if exp.Type == model.ExpenseTypeExpense {
|
||||||
<p class="font-bold text-destructive">
|
<p class="font-bold text-destructive">
|
||||||
- { fmt.Sprintf("$%.2f", float64(expense.AmountCents)/100.0) }
|
- { fmt.Sprintf("$%.2f", float64(exp.AmountCents)/100.0) }
|
||||||
</p>
|
</p>
|
||||||
} else {
|
} else {
|
||||||
<p class="font-bold text-green-500">
|
<p class="font-bold text-green-500">
|
||||||
+ { fmt.Sprintf("$%.2f", float64(expense.AmountCents)/100.0) }
|
+ { fmt.Sprintf("$%.2f", float64(exp.AmountCents)/100.0) }
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
|
// Edit button
|
||||||
|
@dialog.Dialog(dialog.Props{ID: "edit-expense-" + exp.ID}) {
|
||||||
|
@dialog.Trigger() {
|
||||||
|
@button.Button(button.Props{Variant: button.VariantGhost, Size: button.SizeIcon, Class: "size-7"}) {
|
||||||
|
@icon.Pencil(icon.Props{Size: 14})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@dialog.Content() {
|
||||||
|
@dialog.Header() {
|
||||||
|
@dialog.Title() {
|
||||||
|
Edit Transaction
|
||||||
|
}
|
||||||
|
@dialog.Description() {
|
||||||
|
Update the details of this transaction.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@expense.EditExpenseForm(spaceID, exp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Delete button
|
||||||
|
@dialog.Dialog(dialog.Props{ID: "del-expense-" + exp.ID}) {
|
||||||
|
@dialog.Trigger() {
|
||||||
|
@button.Button(button.Props{Variant: button.VariantGhost, Size: button.SizeIcon, Class: "size-7"}) {
|
||||||
|
@icon.Trash2(icon.Props{Size: 14})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@dialog.Content() {
|
||||||
|
@dialog.Header() {
|
||||||
|
@dialog.Title() {
|
||||||
|
Delete Transaction
|
||||||
|
}
|
||||||
|
@dialog.Description() {
|
||||||
|
Are you sure you want to delete "{ exp.Description }"? This action cannot be undone.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@dialog.Footer() {
|
||||||
|
@dialog.Close() {
|
||||||
|
@button.Button(button.Props{Variant: button.VariantOutline}) {
|
||||||
|
Cancel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@button.Button(button.Props{
|
||||||
|
Variant: button.VariantDestructive,
|
||||||
|
Attributes: templ.Attributes{
|
||||||
|
"hx-delete": fmt.Sprintf("/app/spaces/%s/expenses/%s", spaceID, exp.ID),
|
||||||
|
"hx-target": "#expense-" + exp.ID,
|
||||||
|
"hx-swap": "outerHTML",
|
||||||
|
},
|
||||||
|
}) {
|
||||||
|
Delete
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
templ ExpenseCreatedResponse(newExpense *model.Expense, balance int) {
|
templ ExpenseCreatedResponse(spaceID string, newExpense *model.ExpenseWithTags, balance int) {
|
||||||
@ExpenseListItem(newExpense)
|
@ExpenseListItem(spaceID, newExpense)
|
||||||
@expense.BalanceCard(newExpense.SpaceID, balance, true)
|
@expense.BalanceCard(newExpense.SpaceID, balance, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
templ ExpenseUpdatedResponse(spaceID string, exp *model.ExpenseWithTags, balance int) {
|
||||||
|
@ExpenseListItem(spaceID, exp)
|
||||||
|
@expense.BalanceCard(exp.SpaceID, balance, true)
|
||||||
|
}
|
||||||
|
|
|
||||||
263
internal/ui/pages/app_space_settings.templ
Normal file
263
internal/ui/pages/app_space_settings.templ
Normal file
|
|
@ -0,0 +1,263 @@
|
||||||
|
package pages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.juancwu.dev/juancwu/budgit/internal/ctxkeys"
|
||||||
|
"git.juancwu.dev/juancwu/budgit/internal/model"
|
||||||
|
"git.juancwu.dev/juancwu/budgit/internal/ui/components/badge"
|
||||||
|
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
|
||||||
|
"git.juancwu.dev/juancwu/budgit/internal/ui/components/card"
|
||||||
|
"git.juancwu.dev/juancwu/budgit/internal/ui/components/csrf"
|
||||||
|
"git.juancwu.dev/juancwu/budgit/internal/ui/components/dialog"
|
||||||
|
"git.juancwu.dev/juancwu/budgit/internal/ui/components/icon"
|
||||||
|
"git.juancwu.dev/juancwu/budgit/internal/ui/components/input"
|
||||||
|
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
|
||||||
|
)
|
||||||
|
|
||||||
|
templ SpaceSettingsPage(space *model.Space, members []*model.SpaceMemberWithProfile, pendingInvites []*model.SpaceInvitation, isOwner bool, currentUserID string) {
|
||||||
|
@layouts.Space("Settings", space) {
|
||||||
|
<div class="space-y-6 max-w-2xl">
|
||||||
|
// Space Name Section
|
||||||
|
@card.Card() {
|
||||||
|
@card.Header() {
|
||||||
|
@card.Title() {
|
||||||
|
Space Name
|
||||||
|
}
|
||||||
|
@card.Description() {
|
||||||
|
if isOwner {
|
||||||
|
Update the name of this space.
|
||||||
|
} else {
|
||||||
|
The name of this space.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@card.Content() {
|
||||||
|
if isOwner {
|
||||||
|
<form
|
||||||
|
hx-patch={ "/app/spaces/" + space.ID + "/settings/name" }
|
||||||
|
hx-swap="none"
|
||||||
|
class="flex gap-2 items-start"
|
||||||
|
>
|
||||||
|
@csrf.Token()
|
||||||
|
@input.Input(input.Props{
|
||||||
|
Name: "name",
|
||||||
|
Value: space.Name,
|
||||||
|
Attributes: templ.Attributes{
|
||||||
|
"autocomplete": "off",
|
||||||
|
"required": true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@button.Button(button.Props{
|
||||||
|
Type: button.TypeSubmit,
|
||||||
|
}) {
|
||||||
|
Save
|
||||||
|
}
|
||||||
|
</form>
|
||||||
|
} else {
|
||||||
|
<p class="text-sm">{ space.Name }</p>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Members Section
|
||||||
|
@card.Card() {
|
||||||
|
@card.Header() {
|
||||||
|
@card.Title() {
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
@icon.Users(icon.Props{Class: "size-5"})
|
||||||
|
Members
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@card.Description() {
|
||||||
|
People who have access to this space.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@card.Content() {
|
||||||
|
<div class="divide-y" id="members-list">
|
||||||
|
for _, member := range members {
|
||||||
|
@MemberRow(space.ID, member, isOwner, currentUserID)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Invitations Section (owner only)
|
||||||
|
if isOwner {
|
||||||
|
@card.Card() {
|
||||||
|
@card.Header() {
|
||||||
|
@card.Title() {
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
@icon.Mail(icon.Props{Class: "size-5"})
|
||||||
|
Invitations
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@card.Description() {
|
||||||
|
Invite new members and manage pending invitations.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@card.Content() {
|
||||||
|
<div class="space-y-4">
|
||||||
|
<form
|
||||||
|
hx-post={ "/app/spaces/" + space.ID + "/invites" }
|
||||||
|
hx-swap="none"
|
||||||
|
_="on htmx:afterOnLoad if event.detail.xhr.status == 200 reset() me then send refreshInvites to #pending-invites"
|
||||||
|
class="flex gap-2 items-start"
|
||||||
|
>
|
||||||
|
@csrf.Token()
|
||||||
|
@input.Input(input.Props{
|
||||||
|
Name: "email",
|
||||||
|
Placeholder: "Email address...",
|
||||||
|
Attributes: templ.Attributes{
|
||||||
|
"type": "email",
|
||||||
|
"autocomplete": "off",
|
||||||
|
"required": true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@button.Button(button.Props{
|
||||||
|
Type: button.TypeSubmit,
|
||||||
|
}) {
|
||||||
|
@icon.UserPlus(icon.Props{Class: "size-4"})
|
||||||
|
Invite
|
||||||
|
}
|
||||||
|
</form>
|
||||||
|
<div
|
||||||
|
id="pending-invites"
|
||||||
|
hx-get={ "/app/spaces/" + space.ID + "/settings/invites" }
|
||||||
|
hx-trigger="refreshInvites from:body"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
>
|
||||||
|
if len(pendingInvites) > 0 {
|
||||||
|
<h4 class="text-sm font-medium text-muted-foreground mb-2">Pending invitations</h4>
|
||||||
|
<div class="divide-y">
|
||||||
|
for _, invite := range pendingInvites {
|
||||||
|
@PendingInviteRow(space.ID, invite)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<p class="text-sm text-muted-foreground">No pending invitations.</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@dialog.Script()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templ MemberRow(spaceID string, member *model.SpaceMemberWithProfile, isOwner bool, currentUserID string) {
|
||||||
|
<div id={ "member-" + member.UserID } class="flex items-center justify-between py-3">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-sm font-medium">
|
||||||
|
{ string([]rune(member.Name)[0]) }
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium">{ member.Name }</p>
|
||||||
|
<p class="text-xs text-muted-foreground">{ member.Email }</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
if member.Role == model.RoleOwner {
|
||||||
|
@badge.Badge(badge.Props{Variant: badge.VariantDefault}) {
|
||||||
|
@icon.Crown(icon.Props{Class: "size-3"})
|
||||||
|
Owner
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
@badge.Badge(badge.Props{Variant: badge.VariantSecondary}) {
|
||||||
|
Member
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if isOwner && member.UserID != currentUserID && member.Role != model.RoleOwner {
|
||||||
|
{{ dialogID := "remove-member-dialog-" + member.UserID }}
|
||||||
|
@dialog.Dialog(dialog.Props{ID: dialogID}) {
|
||||||
|
@dialog.Trigger() {
|
||||||
|
@button.Button(button.Props{
|
||||||
|
Variant: button.VariantGhost,
|
||||||
|
Size: button.SizeIcon,
|
||||||
|
Type: button.TypeButton,
|
||||||
|
}) {
|
||||||
|
@icon.UserMinus(icon.Props{Class: "size-4 text-destructive"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@dialog.Content() {
|
||||||
|
@dialog.Header() {
|
||||||
|
@dialog.Title() {
|
||||||
|
Remove member
|
||||||
|
}
|
||||||
|
@dialog.Description() {
|
||||||
|
Are you sure you want to remove { member.Name } from this space? They will lose access immediately.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@dialog.Footer() {
|
||||||
|
@dialog.Close() {
|
||||||
|
@button.Button(button.Props{
|
||||||
|
Variant: button.VariantOutline,
|
||||||
|
Type: button.TypeButton,
|
||||||
|
}) {
|
||||||
|
Cancel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@dialog.Close() {
|
||||||
|
@button.Button(button.Props{
|
||||||
|
Variant: button.VariantDestructive,
|
||||||
|
Type: button.TypeButton,
|
||||||
|
Attributes: templ.Attributes{
|
||||||
|
"hx-delete": "/app/spaces/" + spaceID + "/members/" + member.UserID,
|
||||||
|
"hx-target": "#member-" + member.UserID,
|
||||||
|
"hx-swap": "outerHTML",
|
||||||
|
"hx-headers": `{"X-CSRF-Token": "` + ctxkeys.CSRFToken(ctx) + `"}`,
|
||||||
|
},
|
||||||
|
}) {
|
||||||
|
Remove
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ PendingInviteRow(spaceID string, invite *model.SpaceInvitation) {
|
||||||
|
<div id={ "invite-" + invite.Token } class="flex items-center justify-between py-3">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-sm">
|
||||||
|
@icon.Mail(icon.Props{Class: "size-4 text-muted-foreground"})
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium">{ invite.Email }</p>
|
||||||
|
<p class="text-xs text-muted-foreground">Sent { invite.CreatedAt.Format("Jan 02, 2006") }</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
@badge.Badge(badge.Props{Variant: badge.VariantOutline}) {
|
||||||
|
Pending
|
||||||
|
}
|
||||||
|
@button.Button(button.Props{
|
||||||
|
Variant: button.VariantGhost,
|
||||||
|
Size: button.SizeIcon,
|
||||||
|
Type: button.TypeButton,
|
||||||
|
Attributes: templ.Attributes{
|
||||||
|
"hx-delete": "/app/spaces/" + spaceID + "/invites/" + invite.Token,
|
||||||
|
"hx-target": "#invite-" + invite.Token,
|
||||||
|
"hx-swap": "outerHTML",
|
||||||
|
"hx-headers": `{"X-CSRF-Token": "` + ctxkeys.CSRFToken(ctx) + `"}`,
|
||||||
|
},
|
||||||
|
}) {
|
||||||
|
@icon.X(icon.Props{Class: "size-4 text-destructive"})
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ PendingInvitesList(spaceID string, pendingInvites []*model.SpaceInvitation) {
|
||||||
|
if len(pendingInvites) > 0 {
|
||||||
|
<h4 class="text-sm font-medium text-muted-foreground mb-2">Pending invitations</h4>
|
||||||
|
<div class="divide-y">
|
||||||
|
for _, invite := range pendingInvites {
|
||||||
|
@PendingInviteRow(spaceID, invite)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<p class="text-sm text-muted-foreground">No pending invitations.</p>
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue