From b67ec4d0d9476d96761f9ab76fa1be3f32b1766a Mon Sep 17 00:00:00 2001
From: "Dustin C. Hatch"
Date: Mon, 10 Mar 2025 18:25:58 -0500
Subject: [PATCH] receipts: Implement DELETE
A receipt can now be deleted by clicking the little trash can icon on
its card on the receipt list page. To make this look nice, I had to
adjust some of the CSS for that page. Incidentally, I was able to get
the cards to be properly aligned by changing the images to be cropped
instead of scaled, via the `object-fit: cover` CSS property.
---
...aebd46034214df37c1d5b954a69027c2c037f.json | 14 +++
js/receipt-list.ts | 84 ++++++++++++++
src/receipts.rs | 33 ++++++
templates/receipt-list.html.tera | 104 ++++++++++++++----
4 files changed, 214 insertions(+), 21 deletions(-)
create mode 100644 .sqlx/query-1b1fccb2e49acf1402b1c473d4eaebd46034214df37c1d5b954a69027c2c037f.json
diff --git a/.sqlx/query-1b1fccb2e49acf1402b1c473d4eaebd46034214df37c1d5b954a69027c2c037f.json b/.sqlx/query-1b1fccb2e49acf1402b1c473d4eaebd46034214df37c1d5b954a69027c2c037f.json
new file mode 100644
index 0000000..67b89ce
--- /dev/null
+++ b/.sqlx/query-1b1fccb2e49acf1402b1c473d4eaebd46034214df37c1d5b954a69027c2c037f.json
@@ -0,0 +1,14 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "DELETE FROM receipts WHERE id = $1",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Int4"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "1b1fccb2e49acf1402b1c473d4eaebd46034214df37c1d5b954a69027c2c037f"
+}
diff --git a/js/receipt-list.ts b/js/receipt-list.ts
index 18efa4b..acbf852 100644
--- a/js/receipt-list.ts
+++ b/js/receipt-list.ts
@@ -1,5 +1,89 @@
+import "@shoelace-style/shoelace/dist/components/alert/alert.js";
import "@shoelace-style/shoelace/dist/components/button/button.js";
import "@shoelace-style/shoelace/dist/components/card/card.js";
+import "@shoelace-style/shoelace/dist/components/dialog/dialog.js";
import "@shoelace-style/shoelace/dist/components/icon/icon.js";
+import "@shoelace-style/shoelace/dist/components/icon-button/icon-button.js";
import "./shoelace.js";
+
+import SlDialog from "@shoelace-style/shoelace/dist/components/dialog/dialog.js";
+
+import { notify, notifyError } from "./alert.js";
+import { getResponseError } from "./ajaxUtil.js";
+
+const dlgDelete = document.getElementById("confirm-delete") as SlDialog;
+
+const confirmDelete = (
+ id: string,
+ amount: string,
+ date: string,
+ vendor: string,
+) => {
+ dlgDelete.querySelector(".amount")!.textContent = amount;
+ dlgDelete.querySelector(".date")!.textContent = date;
+ dlgDelete.querySelector(".receipt-id")!.textContent = id;
+ dlgDelete.querySelector(".vendor")!.textContent = vendor;
+ dlgDelete.show();
+};
+
+const deleteReceipt = async (receiptId: string): Promise => {
+ let r: Response;
+ try {
+ r = await fetch(`/receipts/${receiptId}`, { method: "DELETE" });
+ } catch (e) {
+ notifyError(e.toString());
+ return false;
+ }
+ if (r.ok) {
+ notify(`Deleted receipt ${receiptId}`);
+ return true;
+ } else {
+ const err = await getResponseError(r);
+ notifyError(err);
+ return false;
+ }
+};
+
+document.querySelectorAll(".receipt-card").forEach((card) => {
+ const amount = card.querySelector(".amount")!.textContent!;
+ const date = (card.querySelector(".date") as HTMLElement).dataset.date!;
+ const vendor = card.querySelector(".vendor")!.textContent!;
+ const btn = card.querySelector("sl-icon-button[name='trash']");
+ if (btn) {
+ btn.addEventListener("click", (evt) => {
+ evt.preventDefault();
+ confirmDelete(
+ (card as HTMLElement).dataset.receiptId!,
+ amount,
+ date,
+ vendor,
+ );
+ });
+ }
+});
+
+dlgDelete
+ .querySelector("sl-button[aria-label='No']")!
+ .addEventListener("click", () => {
+ dlgDelete.hide();
+ });
+
+dlgDelete
+ .querySelector("sl-button[aria-label='Yes']")!
+ .addEventListener("click", async () => {
+ dlgDelete.hide();
+ const receiptId = dlgDelete.querySelector(".receipt-id")?.textContent;
+ if (receiptId) {
+ const success = await deleteReceipt(receiptId);
+ if (success) {
+ const card = document.querySelector(
+ `sl-card[data-receipt-id="${receiptId}"]`,
+ ) as HTMLElement;
+ card.style.opacity = "0";
+ setTimeout(() => {
+ card.remove();
+ }, 1000);
+ }
+ }
+ });
diff --git a/src/receipts.rs b/src/receipts.rs
index c38c4ee..a073945 100644
--- a/src/receipts.rs
+++ b/src/receipts.rs
@@ -69,6 +69,13 @@ pub enum AddReceiptResponse {
Error(String),
}
+#[derive(Serialize)]
+#[serde(rename_all = "lowercase")]
+pub enum DeleteReceiptResponse {
+ Success,
+ Error(String),
+}
+
impl ReceiptPostData {
async fn from_form(
form: &ReceiptPostForm<'_>,
@@ -229,6 +236,7 @@ WHERE
},
}
}
+
#[rocket::get("//view/")]
pub async fn view_receipt_photo(
id: i32,
@@ -257,9 +265,34 @@ pub async fn view_receipt_photo(
}
}
+#[rocket::delete("/")]
+pub async fn delete_receipt(
+ id: i32,
+ mut db: DatabaseConnection,
+) -> (Status, Json) {
+ let result = rocket_db_pools::sqlx::query_as!(
+ ReceiptJson,
+ "DELETE FROM receipts WHERE id = $1",
+ id,
+ )
+ .execute(&mut **db)
+ .await;
+ match result {
+ Ok(_) => (Status::Ok, Json(DeleteReceiptResponse::Success)),
+ Err(e) => {
+ error!("Error fetching receipt image: {}", e);
+ (
+ Status::InternalServerError,
+ Json(DeleteReceiptResponse::Error(e.to_string())),
+ )
+ },
+ }
+}
+
pub fn routes() -> Vec {
rocket::routes![
add_receipt,
+ delete_receipt,
get_receipt,
list_receipts,
receipt_form,
diff --git a/templates/receipt-list.html.tera b/templates/receipt-list.html.tera
index e831834..4b9f275 100644
--- a/templates/receipt-list.html.tera
+++ b/templates/receipt-list.html.tera
@@ -2,12 +2,16 @@
Receipts
{% endblock %} {% block main %}
Receipts
@@ -42,33 +91,46 @@
{% for receipt in receipts -%}
-
-
+
+
+
+
{{ receipt.vendor }}
$ {{ receipt.amount }}
- {{ receipt.date | date(format="%A %_d %B %Y") }}
+
{% endfor %}
+
+
+ Are you sure you want to delete receipt ?
+
+
+ - Vendor
+
+ - Amount
+
+ - Date
+
+
+
+
{% endblock %} {% block scripts %}
-
{% endblock %}