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/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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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 }}">
|
||||||
|
<a slot="image" href="/receipts/{{ receipt.id }}">
|
||||||
<img
|
<img
|
||||||
slot="image"
|
|
||||||
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 %}
|
||||||
|
|
Loading…
Reference in New Issue