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.
bugfix/ci-buildah
Dustin 2025-03-10 18:25:58 -05:00
parent a475f58def
commit b67ec4d0d9
4 changed files with 214 additions and 21 deletions

View File

@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM receipts WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int4"
]
},
"nullable": []
},
"hash": "1b1fccb2e49acf1402b1c473d4eaebd46034214df37c1d5b954a69027c2c037f"
}

View File

@ -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/button/button.js";
import "@shoelace-style/shoelace/dist/components/card/card.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/icon.js";
import "@shoelace-style/shoelace/dist/components/icon-button/icon-button.js";
import "./shoelace.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<boolean> => {
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);
}
}
});

View File

@ -69,6 +69,13 @@ pub enum AddReceiptResponse {
Error(String), Error(String),
} }
#[derive(Serialize)]
#[serde(rename_all = "lowercase")]
pub enum DeleteReceiptResponse {
Success,
Error(String),
}
impl ReceiptPostData { impl ReceiptPostData {
async fn from_form( async fn from_form(
form: &ReceiptPostForm<'_>, form: &ReceiptPostForm<'_>,
@ -229,6 +236,7 @@ WHERE
}, },
} }
} }
#[rocket::get("/<id>/view/<filename>")] #[rocket::get("/<id>/view/<filename>")]
pub async fn view_receipt_photo( pub async fn view_receipt_photo(
id: i32, id: i32,
@ -257,9 +265,34 @@ pub async fn view_receipt_photo(
} }
} }
#[rocket::delete("/<id>")]
pub async fn delete_receipt(
id: i32,
mut db: DatabaseConnection<Database>,
) -> (Status, Json<DeleteReceiptResponse>) {
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<Route> { pub fn routes() -> Vec<Route> {
rocket::routes![ rocket::routes![
add_receipt, add_receipt,
delete_receipt,
get_receipt, get_receipt,
list_receipts, list_receipts,
receipt_form, receipt_form,

View File

@ -2,12 +2,16 @@
<title>Receipts</title> <title>Receipts</title>
<style> <style>
article { article {
text-align: center; display: flex;
flex-wrap: wrap;
justify-content: center;
align-content: stretch;
} }
.receipt-card { .receipt-card {
max-width: 250px; max-width: 250px;
margin: 1em; margin: 1em;
transition: opacity 1s ease;
} }
.receipt-card > a { .receipt-card > a {
@ -24,6 +28,7 @@
.receipt-card .date { .receipt-card .date {
font-size: 75%; font-size: 75%;
color: var(--sl-color-neutral-500); color: var(--sl-color-neutral-500);
vertical-align: bottom;
} }
.receipt-card :link, .receipt-card :link,
@ -31,6 +36,50 @@
color: var(--sl-color-neutral-900); color: var(--sl-color-neutral-900);
text-decoration: none; text-decoration: none;
} }
sl-card.receipt-card::part(base) {
height: 100%;
}
sl-card.receipt-card::part(image) {
height: 100%;
}
sl-card.receipt-card [slot="image"] img {
width: 100%;
height: 100%;
object-fit: cover;
}
sl-card.receipt-card::part(body) {
padding-bottom: calc(var(--padding) / 2);
}
sl-card.receipt-card::part(footer) {
padding: 0 calc(var(--padding) / 2);
}
sl-card.receipt-card footer {
display: flex;
align-items: center;
justify-content: space-between;
}
#confirm-delete dl {
display: grid;
grid-template-columns: max-content max-content;
gap: var(--sl-spacing-x-small) var(--sl-spacing-medium);
padding: 1rem;
margin: 0 0 1.5rem 0;
width: min-content;
}
#confirm-delete dl dt {
text-align: right;
font-weight: bold;
}
#confirm-delete dl dd {
margin-left: 0;
}
</style> </style>
{% endblock %} {% block main %} {% endblock %} {% block main %}
<h1>Receipts</h1> <h1>Receipts</h1>
@ -42,33 +91,46 @@
</p> </p>
<article> <article>
{% for receipt in receipts -%} {% for receipt in receipts -%}
<sl-card class="receipt-card"> <sl-card class="receipt-card" data-receipt-id="{{ receipt.id }}">
<img <a slot="image" href="/receipts/{{ receipt.id }}">
slot="image" <img
src="/receipts/{{ receipt.id }}/view/{{ receipt.filename }}" src="/receipts/{{ receipt.id }}/view/{{ receipt.filename }}"
alt="Receipt {{ receipt.id }}" alt="Receipt {{ receipt.id }}"
/> />
</a>
<a href="/receipts/{{ receipt.id }}"> <a href="/receipts/{{ receipt.id }}">
<div class="vendor">{{ receipt.vendor }}</div> <div class="vendor">{{ receipt.vendor }}</div>
<div class="amount">$ {{ receipt.amount }}</div> <div class="amount">$ {{ receipt.amount }}</div>
</a> </a>
<div class="date">{{ receipt.date | date(format="%A %_d %B %Y") }}</div> <footer slot="footer">
<div
class="date"
data-date="{{ receipt.date | date(format='%A %_d %B %Y') }}"
>
{{ receipt.date | date(format="%a %_d %b %Y") }}
</div>
<sl-icon-button name="trash" label="Delete"></sl-icon-button>
</footer>
</sl-card> </sl-card>
{% endfor %} {% endfor %}
</article> </article>
<sl-dialog id="confirm-delete" label="Delete Receipt">
<p>
Are you sure you want to delete receipt <span class="receipt-id"></span>?
</p>
<dl>
<dt>Vendor</dt>
<dd class="vendor"></dd>
<dt>Amount</dt>
<dd class="amount"></dd>
<dt>Date</dt>
<dd class="date"></dd>
</dl>
<footer slot="footer" class="table-actions">
<sl-button aria-label="No">No</sl-button>
<sl-button variant="danger" aria-label="Yes">Yes</sl-button>
</footer>
</sl-dialog>
{% endblock %} {% block scripts %} {% endblock %} {% block scripts %}
<script src="/static/receipt-list.js"></script> <script src="/static/receipt-list.js"></script>
<script>
(() => {
document.querySelectorAll(".receipt-card").forEach((e) => {
const a = e.querySelector("a");
if (a && a.href) {
e.style.cursor = "pointer";
e.addEventListener("click", () => {
window.location.href = a.href;
});
}
});
})();
</script>
{% endblock %} {% endblock %}