Skip to content

Commit

Permalink
Feature/draft invoices (#503)
Browse files Browse the repository at this point in the history
* removed backend sorting+filtering, added frontend js class that handles tables

* added docs, removed dashboard page test table, added draft statuses

Signed-off-by: Trey <73353716+TreyWW@users.noreply.github.com>
  • Loading branch information
TreyWW authored Sep 29, 2024
1 parent 8356b61 commit c5f6b2f
Show file tree
Hide file tree
Showing 27 changed files with 525 additions and 597 deletions.
1 change: 0 additions & 1 deletion assets/scripts/init.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import Alpine from 'alpinejs'
import $ from 'jquery'


window.Alpine = Alpine

Alpine.start()
Expand Down
228 changes: 228 additions & 0 deletions assets/scripts/tableify.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
window.Tableify = class Tableify {
constructor(selector) {
this.table = $(selector);
this.filters = {};
this.currentSort = null;
this.sortDirection = 0; // 0 for no sort, 1 for ascending, -1 for descending

this.initialize();
}

initialize() {
this.table.find("thead th").each((index, th) => {
const colName = $(th).attr("mft-col-name");
const colFilters = $(th).attr("mft-filters");
const filterType = $(th).attr("mft-filter-type");

// Add filter icon if filters are defined
if (colFilters || filterType === "searchable" || filterType === "searchable-amount" || filterType === "normal") {
const filterIcon = $('<i class="fa fa-filter ml-2 cursor-pointer"></i>');
const countBadge = $('<span class="filter-count hidden ml-1 badge badge-primary"></span>');
$(th).append(filterIcon).append(countBadge);

$(th).addClass('cursor-pointer').on("click", () => {
this.toggleFilter(th, colName);
});
}
});

// Initialize sorting buttons
$('[mft-sort-by]').each((index, button) => {
$(button).on("click", (e) => {
e.preventDefault();
const colName = $(button).attr("mft-sort-by");
this.handleSortButtonClick(colName);
});
});
}

handleSortButtonClick(colName, parentId) {
// Determine the new sort direction
let newSortDirection;

if (this.currentSort === colName) {
// If the same column is clicked again, toggle the direction
newSortDirection = this.sortDirection === 1 ? -1 : (this.sortDirection === -1 ? 0 : 1);
} else {
// New column is clicked, set to ascending
newSortDirection = 1;
}

// Update the current sort column and direction
this.currentSort = newSortDirection === 0 ? null : colName;
this.sortDirection = newSortDirection;

this.redraw(); // Redraw the table with updated sorting
}

redraw() {
const rows = this.table.find("tbody tr");
rows.show(); // Show all rows initially

if (Object.keys(this.filters).length > 0) {
rows.each((index, row) => {
const isVisible = Object.keys(this.filters).every((colName) => {
const filterType = this.table.find(`th[mft-col-name="${colName}"]`).attr("mft-filter-type") || "normal";

// Get the index of the column
const colIndex = this.table.find(`th[mft-col-name="${colName}"]`).index();
const cell = $(row).find(`td:eq(${colIndex})`);

// Get the value to check against the filter
const cellText = cell.text();
const cellValue = cell.attr("td-value") || cellText; // Use td-value if available
const parsedValue = parseFloat(cellValue);

// Perform filtering logic based on filter type
if (filterType === "amount") {
const maxFilter = Math.max(...this.filters[colName].map(f => parseFloat(f)));
return parsedValue >= maxFilter;
} else if (filterType === "searchable") {
return cellText.toLowerCase().includes(this.filters[colName][0].toLowerCase());
} else if (filterType === "searchable-amount") {
const inputValue = this.filters[colName][0];

// Handle both exact match and greater than
if (inputValue.endsWith('+')) {
const numericValue = parseFloat(inputValue.slice(0, -1)); // Remove '+' and parse
return parsedValue >= numericValue; // Include equal and greater
} else {
const numericValue = parseFloat(inputValue); // Exact number
return parsedValue === numericValue; // Exact match
}
} else {
return this.filters[colName].some(filterValue => cellValue.includes(filterValue));
}
});

if (!isVisible) {
$(row).hide();
}
});
}

// Handle sorting if a column is selected for sorting
if (this.currentSort) {
const sortedRows = rows.toArray().sort((a, b) => {
const valA = $(a).find(`td:eq(${this.table.find(`th[mft-col-name="${this.currentSort}"]`).index()})`).text();
const valB = $(b).find(`td:eq(${this.table.find(`th[mft-col-name="${this.currentSort}"]`).index()})`).text();

const isAmount = this.table.find(`th[mft-col-name="${this.currentSort}"]`).attr("mft-filter-type") === "amount";

if (isAmount) {
return (parseFloat(valA) - parseFloat(valB)) * this.sortDirection;
} else {
return (valA < valB ? -1 : (valA > valB ? 1 : 0)) * this.sortDirection;
}
});

this.table.find("tbody").html(sortedRows);
}

this.updateFilterCounts();
}

toggleFilter(element, colName) {
const colFilters = $(element).attr("mft-filters");
const filterType = $(element).attr("mft-filter-type");

// Do nothing if there are no filters or search options defined
if (!colFilters && (filterType !== "searchable" && filterType !== "searchable-amount" && filterType !== "normal")) {
return;
}

let dropdown = $(element).find('.filter-dropdown');
if (dropdown.length === 0) {
dropdown = $('<div class="filter-dropdown absolute bg-base-300 shadow-md rounded-md p-2 mt-1 hidden z-50"></div>');

// Handle different filter types
if (filterType === "normal" && colFilters) {
const filters = colFilters.split(",");
filters.forEach((filter) => {
const checkbox = $(`<label class="flex items-center">
<input type="checkbox" value="${filter}" class="mr-2" />
${filter}
</label>`);
checkbox.on("change", (e) => {
e.stopPropagation(); // Prevent closing on checkbox interaction
this.updateFilter(colName, filter, checkbox.find('input').is(":checked"));
});
dropdown.append(checkbox);
});
} else if (filterType === "searchable") {
// Create search input for searchable columns
const searchInput = $(`<input type="text" class="input input-bordered w-full input-sm" placeholder="Search..." />`);
searchInput.on("input", (e) => {
e.stopPropagation(); // Prevent closing on typing
const searchValue = searchInput.val();
if (searchValue) {
this.filters[colName] = [searchValue];
} else {
delete this.filters[colName];
}
this.redraw();
});
dropdown.append(searchInput);
} else if (filterType === "searchable-amount") {
// Create numeric input for searchable-amount columns
const amountInput = $(`<input type="text" class="input input-bordered w-full input-sm" placeholder="Enter amount or amount+ for greater..." />`);
amountInput.on("input", (e) => {
e.stopPropagation(); // Prevent closing on typing
const amountValue = amountInput.val();

if (amountValue) {
this.filters[colName] = [amountValue]; // Store as is
} else {
delete this.filters[colName]; // Remove if empty
}

this.redraw();
});
dropdown.append(amountInput);
}

$(element).append(dropdown); // Append the new dropdown to the column header

// Prevent dropdown from closing when clicking inside
dropdown.on("click", (e) => {
e.stopPropagation(); // Prevent propagation of clicks within the dropdown
});
}

// Toggle dropdown visibility
dropdown.toggleClass('hidden');

// Close dropdown when clicking outside
const handleClickOutside = (e) => {
if (!$(e.target).closest(dropdown).length && !$(e.target).closest(element).length) {
dropdown.addClass('hidden');
$(document).off('click', handleClickOutside); // Remove event listener when dropdown is closed
}
};

$(document).on('click', handleClickOutside); // Attach event listener to close dropdown when clicking outside
}

updateFilter(colName, value, checked) {
if (!this.filters[colName]) this.filters[colName] = [];

if (checked) {
if (!this.filters[colName].includes(value)) {
this.filters[colName].push(value);
}
} else {
this.filters[colName] = this.filters[colName].filter((val) => val !== value);
if (this.filters[colName].length === 0) delete this.filters[colName];
}

this.redraw();
}

updateFilterCounts() {
this.table.find("thead th").each((index, th) => {
const colName = $(th).attr("mft-col-name");
const count = this.filters[colName] ? this.filters[colName].length : 0;
$(th).find('.filter-count').text(count).toggleClass('hidden', count === 0);
});
}
}
16 changes: 4 additions & 12 deletions backend/api/invoices/edit.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,23 +89,15 @@ def change_status(request: HtmxHttpRequest, invoice_id: int, status: str) -> Htt
if request.user.logged_in_as_team and request.user.logged_in_as_team != invoice.organization or request.user != invoice.user:
return return_message(request, "You don't have permission to make changes to this invoice.")

if status not in ["paid", "overdue", "pending"]:
return return_message(request, "Invalid status. Please choose from: pending, paid, overdue")
if status not in ["paid", "draft", "pending"]:
return return_message(request, "Invalid status. Please choose from: pending, paid, draft")

if invoice.payment_status == status:
if invoice.status == status:
return return_message(request, f"Invoice status is already {status}")

invoice.payment_status = status
invoice.status = status
invoice.save()

dps = invoice.dynamic_payment_status
if (status == "overdue" and dps == "pending") or (status == "pending" and dps == "overdue"):
message = f"""
The invoice status was automatically changed from <strong>{status}</strong> to <strong>{dps}</strong>
as the invoice dates override the manual status.
"""
return return_message(request, message, success=False)

send_message(request, f"Invoice status been changed to <strong>{status}</strong>", success=True)

return render(request, "pages/invoices/dashboard/_modify_payment_status.html", {"status": status, "invoice_id": invoice_id})
Expand Down
45 changes: 5 additions & 40 deletions backend/api/invoices/fetch.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
from django.db.models import When, CharField, F
from django.db.models.expressions import Case, Value
from django.shortcuts import render, redirect
from django.utils import timezone
from django.views.decorators.http import require_http_methods

from backend.decorators import web_require_scopes
Expand All @@ -23,44 +20,12 @@ def fetch_all_invoices(request: HtmxHttpRequest):
invoices = Invoice.objects.filter(user=request.user)

# Get filter and sort parameters from the request
sort_by = request.GET.get("sort")
sort_direction = request.GET.get("sort_direction", "")
action_filter_type = request.GET.get("filter_type")
action_filter_by = request.GET.get("filter")
# sort_by = request.GET.get("sort")
# sort_direction = request.GET.get("sort_direction", "")
# action_filter_type = request.GET.get("filter_type")
# action_filter_by = request.GET.get("filter")

# Define previous filters as a dictionary
previous_filters = {
"payment_status": {
"paid": True if request.GET.get("payment_status_paid") else False,
"pending": True if request.GET.get("payment_status_pending") else False,
"overdue": True if request.GET.get("payment_status_overdue") else False,
},
"amount": {
"20+": True if request.GET.get("amount_20+") else False,
"50+": True if request.GET.get("amount_50+") else False,
"100+": True if request.GET.get("amount_100+") else False,
},
}

context, invoices = get_context(invoices, sort_by, previous_filters, sort_direction, action_filter_type, action_filter_by)

# check/update payment status to make sure it is correct before invoices are filtered and displayed
invoices.update(
payment_status=Case(
When(
date_due__lt=timezone.now().date(),
payment_status="pending",
then=Value("overdue"),
),
When(
date_due__gt=timezone.now().date(),
payment_status="overdue",
then=Value("pending"),
),
default=F("payment_status"),
output_field=CharField(),
)
)
context, invoices = get_context(invoices)

# Render the HTMX response
return render(request, "pages/invoices/dashboard/_fetch_body.html", context)
27 changes: 5 additions & 22 deletions backend/api/invoices/recurring/fetch.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,29 +18,12 @@ def fetch_all_recurring_invoices_endpoint(request: WebRequest):
invoices = InvoiceRecurringProfile.filter_by_owner(owner=request.actor).filter(active=True)

# Get filter and sort parameters from the request
sort_by = request.GET.get("sort")
sort_direction = request.GET.get("sort_direction", "")
action_filter_type = request.GET.get("filter_type")
action_filter_by = request.GET.get("filter")
# sort_by = request.GET.get("sort")
# sort_direction = request.GET.get("sort_direction", "")
# action_filter_type = request.GET.get("filter_type")
# action_filter_by = request.GET.get("filter")

# Define previous filters as a dictionary
previous_filters = {
"status": {
"ongoing": True if request.GET.get("status_ongoing") else False,
"paused": True if request.GET.get("status_paused") else False,
"cancelled": True if request.GET.get("status_cancelled") else False,
}
}

context, _ = get_context(invoices, sort_by, previous_filters, sort_direction, action_filter_type, action_filter_by)

previous_amount_filter = request.GET.get("amount_filter")

amount_filter = previous_amount_filter if previous_amount_filter else action_filter_by if action_filter_type == "amount" else None

if amount_filter:
context["invoices"] = context["invoices"].filter(amount__gte=amount_filter)
context["amount_filter"] = amount_filter
context, _ = get_context(invoices)

paginator = Paginator(context["invoices"], 8)

Expand Down
16 changes: 4 additions & 12 deletions backend/api/public/endpoints/Invoices/edit.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,23 +82,15 @@ def change_status_endpoint(request, invoice_id: int, invoice_status: str):
if request.user.logged_in_as_team and request.user.logged_in_as_team != invoice.organization or request.user != invoice.user:
return Response({"error": "You do not have permission to edit this invoice"}, status=status.HTTP_403_FORBIDDEN)

if invoice_status not in ["paid", "overdue", "pending"]:
return Response({"error": "Invalid status. Please choose from: pending, paid, overdue"}, status=status.HTTP_400_BAD_REQUEST)
if invoice_status not in ["paid", "draft", "pending"]:
return Response({"error": "Invalid status. Please choose from: pending, paid, draft"}, status=status.HTTP_400_BAD_REQUEST)

if invoice.payment_status == invoice_status:
if invoice.status == invoice_status:
return Response({"error": f"Invoice status is already {invoice_status}"}, status=status.HTTP_400_BAD_REQUEST)

invoice.payment_status = invoice_status
invoice.status = invoice_status
invoice.save()

dps = invoice.dynamic_payment_status
if (invoice_status == "overdue" and dps == "pending") or (invoice_status == "pending" and dps == "overdue"):
message = f"""
The invoice status was automatically changed from <strong>{invoice_status}</strong> to <strong>{dps}</strong>
as the invoice dates override the manual status.
"""
return Response({"error": message}, status=status.HTTP_400_BAD_REQUEST)

return Response({"message": f"Invoice status been changed to <strong>{invoice_status}</strong>"}, status=status.HTTP_200_OK)


Expand Down
Loading

0 comments on commit c5f6b2f

Please sign in to comment.