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 @@

+ +

+ Are you sure you want to delete receipt ? +

+
+
Vendor
+
+
Amount
+
+
Date
+
+
+
+ No + Yes +
+
{% endblock %} {% block scripts %} - {% endblock %}