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
parent
a475f58def
commit
b67ec4d0d9
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM receipts WHERE id = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int4"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "1b1fccb2e49acf1402b1c473d4eaebd46034214df37c1d5b954a69027c2c037f"
|
||||
}
|
|
@ -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<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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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("/<id>/view/<filename>")]
|
||||
pub async fn view_receipt_photo(
|
||||
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> {
|
||||
rocket::routes![
|
||||
add_receipt,
|
||||
delete_receipt,
|
||||
get_receipt,
|
||||
list_receipts,
|
||||
receipt_form,
|
||||
|
|
|
@ -2,12 +2,16 @@
|
|||
<title>Receipts</title>
|
||||
<style>
|
||||
article {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
align-content: stretch;
|
||||
}
|
||||
|
||||
.receipt-card {
|
||||
max-width: 250px;
|
||||
margin: 1em;
|
||||
transition: opacity 1s ease;
|
||||
}
|
||||
|
||||
.receipt-card > a {
|
||||
|
@ -24,6 +28,7 @@
|
|||
.receipt-card .date {
|
||||
font-size: 75%;
|
||||
color: var(--sl-color-neutral-500);
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
.receipt-card :link,
|
||||
|
@ -31,6 +36,50 @@
|
|||
color: var(--sl-color-neutral-900);
|
||||
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>
|
||||
{% endblock %} {% block main %}
|
||||
<h1>Receipts</h1>
|
||||
|
@ -42,33 +91,46 @@
|
|||
</p>
|
||||
<article>
|
||||
{% for receipt in receipts -%}
|
||||
<sl-card class="receipt-card">
|
||||
<sl-card class="receipt-card" data-receipt-id="{{ receipt.id }}">
|
||||
<a slot="image" href="/receipts/{{ receipt.id }}">
|
||||
<img
|
||||
slot="image"
|
||||
src="/receipts/{{ receipt.id }}/view/{{ receipt.filename }}"
|
||||
alt="Receipt {{ receipt.id }}"
|
||||
/>
|
||||
</a>
|
||||
<a href="/receipts/{{ receipt.id }}">
|
||||
<div class="vendor">{{ receipt.vendor }}</div>
|
||||
<div class="amount">$ {{ receipt.amount }}</div>
|
||||
</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>
|
||||
{% endfor %}
|
||||
</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 %}
|
||||
<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 %}
|
||||
|
|
Loading…
Reference in New Issue