Compare commits
No commits in common. "master" and "old" have entirely different histories.
|
@ -1,8 +0,0 @@
|
||||||
*
|
|
||||||
!Cargo.*
|
|
||||||
!.sqlx/
|
|
||||||
!js/
|
|
||||||
!migrations/
|
|
||||||
!src/
|
|
||||||
!sql/
|
|
||||||
!templates/
|
|
|
@ -1,26 +0,0 @@
|
||||||
root = true
|
|
||||||
|
|
||||||
[*]
|
|
||||||
end_of_line = lf
|
|
||||||
insert_final_newline =true
|
|
||||||
charset = utf-8
|
|
||||||
|
|
||||||
[*.{html,css,tera}]
|
|
||||||
indent_style = space
|
|
||||||
indent_size = 2
|
|
||||||
|
|
||||||
[*.rs]
|
|
||||||
indent_style = space
|
|
||||||
indent_size = 4
|
|
||||||
|
|
||||||
[*.{js,ts}]
|
|
||||||
indent_style = space
|
|
||||||
indent_size = 4
|
|
||||||
|
|
||||||
[*.json]
|
|
||||||
indent_style = space
|
|
||||||
indent_size = 2
|
|
||||||
|
|
||||||
[*.sql]
|
|
||||||
indent_style = space
|
|
||||||
indent_size = 4
|
|
|
@ -1,4 +1 @@
|
||||||
/Rocket.toml
|
|
||||||
/firefly.token
|
|
||||||
/target
|
/target
|
||||||
/.postgresql
|
|
||||||
|
|
10
.prettierrc
10
.prettierrc
|
@ -1,10 +0,0 @@
|
||||||
{
|
|
||||||
"overrides": [
|
|
||||||
{
|
|
||||||
"files": "*.html.tera",
|
|
||||||
"options": {
|
|
||||||
"parser": "html"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
|
@ -1,14 +0,0 @@
|
||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "DELETE FROM receipts WHERE id = $1",
|
|
||||||
"describe": {
|
|
||||||
"columns": [],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Int4"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": []
|
|
||||||
},
|
|
||||||
"hash": "1b1fccb2e49acf1402b1c473d4eaebd46034214df37c1d5b954a69027c2c037f"
|
|
||||||
}
|
|
|
@ -1,20 +0,0 @@
|
||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "SELECT\n count(id) AS \"count!\"\nFROM\n receipts\n",
|
|
||||||
"describe": {
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"ordinal": 0,
|
|
||||||
"name": "count!",
|
|
||||||
"type_info": "Int8"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"parameters": {
|
|
||||||
"Left": []
|
|
||||||
},
|
|
||||||
"nullable": [
|
|
||||||
null
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"hash": "34f56cde503100c09bbb378ce656af95abd81949be0c369a5d7225272e6c9c58"
|
|
||||||
}
|
|
|
@ -1,57 +0,0 @@
|
||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "INSERT INTO receipts (\n vendor, date, amount, notes, filename, image\n) VALUES (\n $1, $2, $3, $4, $5, $6\n)\nRETURNING\n id, vendor, date, amount, notes, filename\n",
|
|
||||||
"describe": {
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"ordinal": 0,
|
|
||||||
"name": "id",
|
|
||||||
"type_info": "Int4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 1,
|
|
||||||
"name": "vendor",
|
|
||||||
"type_info": "Varchar"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 2,
|
|
||||||
"name": "date",
|
|
||||||
"type_info": "Timestamptz"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 3,
|
|
||||||
"name": "amount",
|
|
||||||
"type_info": "Numeric"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 4,
|
|
||||||
"name": "notes",
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 5,
|
|
||||||
"name": "filename",
|
|
||||||
"type_info": "Varchar"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Varchar",
|
|
||||||
"Timestamptz",
|
|
||||||
"Numeric",
|
|
||||||
"Text",
|
|
||||||
"Varchar",
|
|
||||||
"Bytea"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": [
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
false
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"hash": "a3fb24d192843a656565cebcc78a49f5cd14598e029d6bacfeb2326daf142119"
|
|
||||||
}
|
|
|
@ -1,28 +0,0 @@
|
||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "SELECT filename, image FROM receipts WHERE id = $1",
|
|
||||||
"describe": {
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"ordinal": 0,
|
|
||||||
"name": "filename",
|
|
||||||
"type_info": "Varchar"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 1,
|
|
||||||
"name": "image",
|
|
||||||
"type_info": "Bytea"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Int4"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": [
|
|
||||||
false,
|
|
||||||
false
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"hash": "da76776380d89783df7270592d03afbd07c627342af3d03dc83932b152402941"
|
|
||||||
}
|
|
|
@ -1,52 +0,0 @@
|
||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "SELECT\n id, vendor, date, amount, notes, filename\nFROM\n receipts\nWHERE\n id = $1\n",
|
|
||||||
"describe": {
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"ordinal": 0,
|
|
||||||
"name": "id",
|
|
||||||
"type_info": "Int4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 1,
|
|
||||||
"name": "vendor",
|
|
||||||
"type_info": "Varchar"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 2,
|
|
||||||
"name": "date",
|
|
||||||
"type_info": "Timestamptz"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 3,
|
|
||||||
"name": "amount",
|
|
||||||
"type_info": "Numeric"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 4,
|
|
||||||
"name": "notes",
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 5,
|
|
||||||
"name": "filename",
|
|
||||||
"type_info": "Varchar"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Int4"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": [
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
false
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"hash": "e173b8a98dcdf9e4b259ae59e0ddcceef07851bbe9b429fd62b9d02bf01f52e1"
|
|
||||||
}
|
|
|
@ -1,53 +0,0 @@
|
||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "SELECT\n id, vendor, date, amount, notes, filename\nFROM\n receipts\nORDER BY\n date DESC,\n id DESC\nLIMIT $1\nOFFSET $2\n",
|
|
||||||
"describe": {
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"ordinal": 0,
|
|
||||||
"name": "id",
|
|
||||||
"type_info": "Int4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 1,
|
|
||||||
"name": "vendor",
|
|
||||||
"type_info": "Varchar"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 2,
|
|
||||||
"name": "date",
|
|
||||||
"type_info": "Timestamptz"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 3,
|
|
||||||
"name": "amount",
|
|
||||||
"type_info": "Numeric"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 4,
|
|
||||||
"name": "notes",
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 5,
|
|
||||||
"name": "filename",
|
|
||||||
"type_info": "Varchar"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Int8",
|
|
||||||
"Int8"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": [
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
false
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"hash": "ed7bf495d2eefe7b479a79cc2fc77de3b5a3db4415cd55ecbd21c28c108274a6"
|
|
||||||
}
|
|
File diff suppressed because it is too large
Load Diff
19
Cargo.toml
19
Cargo.toml
|
@ -1,24 +1,11 @@
|
||||||
[package]
|
[package]
|
||||||
name = "receipts"
|
name = "receipts"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
authors = ["Dustin C. Hatch <dustin@hatch.name>"]
|
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.80"
|
|
||||||
description = "Receipts management tool for Firefly-III"
|
|
||||||
homepage = "https://receipts.pyrocufflink.blue/"
|
|
||||||
license = "MIT OR Apache-2.0"
|
|
||||||
keywords = ["personal-finance", "receipts"]
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
chrono = { version = "0.4.40", default-features = false, features = ["serde"] }
|
axum = { version = "0.8.1", default-features = false, features = ["form", "http1", "multipart", "tokio", "tracing"] }
|
||||||
graphicsmagick = { version = "0.6.1", features = ["v1_3_38"] }
|
tokio = { version = "1.43.0", default-features = false, features = ["rt", "macros", "net", "signal", "tracing"] }
|
||||||
reqwest = { version = "0.12.12", features = ["json"] }
|
tower-http = { version = "0.6.2", features = ["limit", "trace"] }
|
||||||
rocket = { version = "0.5.1", default-features = false, features = ["json"] }
|
|
||||||
rocket_db_pools = { version = "0.2.0", features = ["sqlx_macros", "sqlx_postgres"] }
|
|
||||||
rocket_dyn_templates = { version = "0.2.0", features = ["tera"] }
|
|
||||||
rust_decimal = { version = "1.36.0", features = ["serde-with-str"] }
|
|
||||||
serde = { version = "1.0.218", default-features = false, features = ["derive"] }
|
|
||||||
sqlx = { version = "~0.7.4", default-features = false, features = ["chrono", "macros", "migrate", "postgres", "rust_decimal", "time"] }
|
|
||||||
thiserror = "2.0.12"
|
|
||||||
tracing = "0.1.41"
|
tracing = "0.1.41"
|
||||||
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
|
||||||
|
|
|
@ -1,62 +0,0 @@
|
||||||
FROM git.pyrocufflink.net/containerimages/dch-base AS build
|
|
||||||
|
|
||||||
RUN --mount=type=cache,target=/var/cache \
|
|
||||||
microdnf install -y \
|
|
||||||
--setopt persistdir=/var/cache/dnf \
|
|
||||||
--setopt install_weak_deps=0 \
|
|
||||||
GraphicsMagick-devel \
|
|
||||||
cargo \
|
|
||||||
clang-devel \
|
|
||||||
openssl-devel \
|
|
||||||
&& :
|
|
||||||
|
|
||||||
WORKDIR /build
|
|
||||||
|
|
||||||
COPY Cargo.* .
|
|
||||||
COPY src src
|
|
||||||
COPY .sqlx .sqlx
|
|
||||||
COPY migrations migrations
|
|
||||||
COPY sql sql
|
|
||||||
|
|
||||||
RUN --mount=type=cache,target=/root/.cargo \
|
|
||||||
cargo build --release --locked
|
|
||||||
|
|
||||||
FROM git.pyrocufflink.net/containerimages/dch-base AS esbuild
|
|
||||||
|
|
||||||
RUN --mount=type=cache,target=/var/cache \
|
|
||||||
microdnf install -y \
|
|
||||||
--setopt persistdir=/var/cache/dnf \
|
|
||||||
--setopt install_weak_deps=0 \
|
|
||||||
npm \
|
|
||||||
&& :
|
|
||||||
|
|
||||||
WORKDIR /build
|
|
||||||
|
|
||||||
COPY js .
|
|
||||||
|
|
||||||
RUN --mount=type=cache,target=/root/.cargo \
|
|
||||||
npm ci && npm run build
|
|
||||||
|
|
||||||
|
|
||||||
FROM git.pyrocufflink.net/containerimages/dch-base
|
|
||||||
|
|
||||||
RUN --mount=type=cache,target=/var/cache \
|
|
||||||
microdnf install -y \
|
|
||||||
--setopt persistdir=/var/cache/dnf \
|
|
||||||
--setopt install_weak_deps=0 \
|
|
||||||
GraphicsMagick \
|
|
||||||
clang-libs \
|
|
||||||
ghostscript \
|
|
||||||
&& :
|
|
||||||
|
|
||||||
COPY --from=build /build/target/release/receipts /usr/local/bin
|
|
||||||
|
|
||||||
COPY --from=esbuild /build/dist /usr/local/share/receipts/static
|
|
||||||
|
|
||||||
COPY templates /usr/local/share/receipts/templates
|
|
||||||
|
|
||||||
WORKDIR /usr/local/share/receipts
|
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/local/bin/receipts"]
|
|
||||||
|
|
||||||
ENV ROCKET_CONFIG=/etc/receipts/config.toml
|
|
|
@ -1,201 +0,0 @@
|
||||||
Apache License
|
|
||||||
Version 2.0, January 2004
|
|
||||||
http://www.apache.org/licenses/
|
|
||||||
|
|
||||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
||||||
|
|
||||||
1. Definitions.
|
|
||||||
|
|
||||||
"License" shall mean the terms and conditions for use, reproduction,
|
|
||||||
and distribution as defined by Sections 1 through 9 of this document.
|
|
||||||
|
|
||||||
"Licensor" shall mean the copyright owner or entity authorized by
|
|
||||||
the copyright owner that is granting the License.
|
|
||||||
|
|
||||||
"Legal Entity" shall mean the union of the acting entity and all
|
|
||||||
other entities that control, are controlled by, or are under common
|
|
||||||
control with that entity. For the purposes of this definition,
|
|
||||||
"control" means (i) the power, direct or indirect, to cause the
|
|
||||||
direction or management of such entity, whether by contract or
|
|
||||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
||||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
||||||
|
|
||||||
"You" (or "Your") shall mean an individual or Legal Entity
|
|
||||||
exercising permissions granted by this License.
|
|
||||||
|
|
||||||
"Source" form shall mean the preferred form for making modifications,
|
|
||||||
including but not limited to software source code, documentation
|
|
||||||
source, and configuration files.
|
|
||||||
|
|
||||||
"Object" form shall mean any form resulting from mechanical
|
|
||||||
transformation or translation of a Source form, including but
|
|
||||||
not limited to compiled object code, generated documentation,
|
|
||||||
and conversions to other media types.
|
|
||||||
|
|
||||||
"Work" shall mean the work of authorship, whether in Source or
|
|
||||||
Object form, made available under the License, as indicated by a
|
|
||||||
copyright notice that is included in or attached to the work
|
|
||||||
(an example is provided in the Appendix below).
|
|
||||||
|
|
||||||
"Derivative Works" shall mean any work, whether in Source or Object
|
|
||||||
form, that is based on (or derived from) the Work and for which the
|
|
||||||
editorial revisions, annotations, elaborations, or other modifications
|
|
||||||
represent, as a whole, an original work of authorship. For the purposes
|
|
||||||
of this License, Derivative Works shall not include works that remain
|
|
||||||
separable from, or merely link (or bind by name) to the interfaces of,
|
|
||||||
the Work and Derivative Works thereof.
|
|
||||||
|
|
||||||
"Contribution" shall mean any work of authorship, including
|
|
||||||
the original version of the Work and any modifications or additions
|
|
||||||
to that Work or Derivative Works thereof, that is intentionally
|
|
||||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
||||||
or by an individual or Legal Entity authorized to submit on behalf of
|
|
||||||
the copyright owner. For the purposes of this definition, "submitted"
|
|
||||||
means any form of electronic, verbal, or written communication sent
|
|
||||||
to the Licensor or its representatives, including but not limited to
|
|
||||||
communication on electronic mailing lists, source code control systems,
|
|
||||||
and issue tracking systems that are managed by, or on behalf of, the
|
|
||||||
Licensor for the purpose of discussing and improving the Work, but
|
|
||||||
excluding communication that is conspicuously marked or otherwise
|
|
||||||
designated in writing by the copyright owner as "Not a Contribution."
|
|
||||||
|
|
||||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
||||||
on behalf of whom a Contribution has been received by Licensor and
|
|
||||||
subsequently incorporated within the Work.
|
|
||||||
|
|
||||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
||||||
this License, each Contributor hereby grants to You a perpetual,
|
|
||||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
||||||
copyright license to reproduce, prepare Derivative Works of,
|
|
||||||
publicly display, publicly perform, sublicense, and distribute the
|
|
||||||
Work and such Derivative Works in Source or Object form.
|
|
||||||
|
|
||||||
3. Grant of Patent License. Subject to the terms and conditions of
|
|
||||||
this License, each Contributor hereby grants to You a perpetual,
|
|
||||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
||||||
(except as stated in this section) patent license to make, have made,
|
|
||||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
||||||
where such license applies only to those patent claims licensable
|
|
||||||
by such Contributor that are necessarily infringed by their
|
|
||||||
Contribution(s) alone or by combination of their Contribution(s)
|
|
||||||
with the Work to which such Contribution(s) was submitted. If You
|
|
||||||
institute patent litigation against any entity (including a
|
|
||||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
||||||
or a Contribution incorporated within the Work constitutes direct
|
|
||||||
or contributory patent infringement, then any patent licenses
|
|
||||||
granted to You under this License for that Work shall terminate
|
|
||||||
as of the date such litigation is filed.
|
|
||||||
|
|
||||||
4. Redistribution. You may reproduce and distribute copies of the
|
|
||||||
Work or Derivative Works thereof in any medium, with or without
|
|
||||||
modifications, and in Source or Object form, provided that You
|
|
||||||
meet the following conditions:
|
|
||||||
|
|
||||||
(a) You must give any other recipients of the Work or
|
|
||||||
Derivative Works a copy of this License; and
|
|
||||||
|
|
||||||
(b) You must cause any modified files to carry prominent notices
|
|
||||||
stating that You changed the files; and
|
|
||||||
|
|
||||||
(c) You must retain, in the Source form of any Derivative Works
|
|
||||||
that You distribute, all copyright, patent, trademark, and
|
|
||||||
attribution notices from the Source form of the Work,
|
|
||||||
excluding those notices that do not pertain to any part of
|
|
||||||
the Derivative Works; and
|
|
||||||
|
|
||||||
(d) If the Work includes a "NOTICE" text file as part of its
|
|
||||||
distribution, then any Derivative Works that You distribute must
|
|
||||||
include a readable copy of the attribution notices contained
|
|
||||||
within such NOTICE file, excluding those notices that do not
|
|
||||||
pertain to any part of the Derivative Works, in at least one
|
|
||||||
of the following places: within a NOTICE text file distributed
|
|
||||||
as part of the Derivative Works; within the Source form or
|
|
||||||
documentation, if provided along with the Derivative Works; or,
|
|
||||||
within a display generated by the Derivative Works, if and
|
|
||||||
wherever such third-party notices normally appear. The contents
|
|
||||||
of the NOTICE file are for informational purposes only and
|
|
||||||
do not modify the License. You may add Your own attribution
|
|
||||||
notices within Derivative Works that You distribute, alongside
|
|
||||||
or as an addendum to the NOTICE text from the Work, provided
|
|
||||||
that such additional attribution notices cannot be construed
|
|
||||||
as modifying the License.
|
|
||||||
|
|
||||||
You may add Your own copyright statement to Your modifications and
|
|
||||||
may provide additional or different license terms and conditions
|
|
||||||
for use, reproduction, or distribution of Your modifications, or
|
|
||||||
for any such Derivative Works as a whole, provided Your use,
|
|
||||||
reproduction, and distribution of the Work otherwise complies with
|
|
||||||
the conditions stated in this License.
|
|
||||||
|
|
||||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
||||||
any Contribution intentionally submitted for inclusion in the Work
|
|
||||||
by You to the Licensor shall be under the terms and conditions of
|
|
||||||
this License, without any additional terms or conditions.
|
|
||||||
Notwithstanding the above, nothing herein shall supersede or modify
|
|
||||||
the terms of any separate license agreement you may have executed
|
|
||||||
with Licensor regarding such Contributions.
|
|
||||||
|
|
||||||
6. Trademarks. This License does not grant permission to use the trade
|
|
||||||
names, trademarks, service marks, or product names of the Licensor,
|
|
||||||
except as required for reasonable and customary use in describing the
|
|
||||||
origin of the Work and reproducing the content of the NOTICE file.
|
|
||||||
|
|
||||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
||||||
agreed to in writing, Licensor provides the Work (and each
|
|
||||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
||||||
implied, including, without limitation, any warranties or conditions
|
|
||||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
||||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
||||||
appropriateness of using or redistributing the Work and assume any
|
|
||||||
risks associated with Your exercise of permissions under this License.
|
|
||||||
|
|
||||||
8. Limitation of Liability. In no event and under no legal theory,
|
|
||||||
whether in tort (including negligence), contract, or otherwise,
|
|
||||||
unless required by applicable law (such as deliberate and grossly
|
|
||||||
negligent acts) or agreed to in writing, shall any Contributor be
|
|
||||||
liable to You for damages, including any direct, indirect, special,
|
|
||||||
incidental, or consequential damages of any character arising as a
|
|
||||||
result of this License or out of the use or inability to use the
|
|
||||||
Work (including but not limited to damages for loss of goodwill,
|
|
||||||
work stoppage, computer failure or malfunction, or any and all
|
|
||||||
other commercial damages or losses), even if such Contributor
|
|
||||||
has been advised of the possibility of such damages.
|
|
||||||
|
|
||||||
9. Accepting Warranty or Additional Liability. While redistributing
|
|
||||||
the Work or Derivative Works thereof, You may choose to offer,
|
|
||||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
||||||
or other liability obligations and/or rights consistent with this
|
|
||||||
License. However, in accepting such obligations, You may act only
|
|
||||||
on Your own behalf and on Your sole responsibility, not on behalf
|
|
||||||
of any other Contributor, and only if You agree to indemnify,
|
|
||||||
defend, and hold each Contributor harmless for any liability
|
|
||||||
incurred by, or claims asserted against, such Contributor by reason
|
|
||||||
of your accepting any such warranty or additional liability.
|
|
||||||
|
|
||||||
END OF TERMS AND CONDITIONS
|
|
||||||
|
|
||||||
APPENDIX: How to apply the Apache License to your work.
|
|
||||||
|
|
||||||
To apply the Apache License to your work, attach the following
|
|
||||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
||||||
replaced with your own identifying information. (Don't include
|
|
||||||
the brackets!) The text should be enclosed in the appropriate
|
|
||||||
comment syntax for the file format. We also recommend that a
|
|
||||||
file or class name and description of purpose be included on the
|
|
||||||
same "printed page" as the copyright notice for easier
|
|
||||||
identification within third-party archives.
|
|
||||||
|
|
||||||
Copyright [yyyy] [name of copyright owner]
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
|
@ -1,21 +0,0 @@
|
||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) [year] [fullname]
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
|
@ -1,10 +0,0 @@
|
||||||
@Library('containerimages')
|
|
||||||
@Library('dch')
|
|
||||||
_
|
|
||||||
|
|
||||||
buildContainerImage2(project: 'packages', defaultBranch: 'master')
|
|
||||||
stage('Deploy') {
|
|
||||||
when(env.BRANCH_NAME == "master") {
|
|
||||||
kubeRestartDeployment()
|
|
||||||
}
|
|
||||||
}
|
|
22
createdb.sh
22
createdb.sh
|
@ -1,22 +0,0 @@
|
||||||
#!/bin/sh
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
C() {
|
|
||||||
podman exec -u postgres postgresql "$@"
|
|
||||||
}
|
|
||||||
|
|
||||||
until C pg_isready; do sleep 1; done
|
|
||||||
|
|
||||||
if
|
|
||||||
! C psql -At -c 'SELECT 1 FROM pg_user WHERE usename = '\'receipts\' \
|
|
||||||
| grep -q .
|
|
||||||
then
|
|
||||||
C createuser -DERS receipts
|
|
||||||
fi
|
|
||||||
if
|
|
||||||
! C psql -At -c 'SELECT 1 FROM pg_database WHERE datname = '\'receipts\' \
|
|
||||||
| grep -q .
|
|
||||||
then
|
|
||||||
C createdb -O receipts receipts
|
|
||||||
fi
|
|
|
@ -1,3 +0,0 @@
|
||||||
DB_CONNECTION=sqlite
|
|
||||||
APP_KEY=FVo8gylkwKlgtXbn4hjcdCuekDEbGyl2
|
|
||||||
MAIL_MAILER=log
|
|
|
@ -0,0 +1,72 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Upload Receipts</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css" />
|
||||||
|
<style>
|
||||||
|
#previews img {
|
||||||
|
padding: 0.25em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>Upload Receipts</h1>
|
||||||
|
<form method="post" enctype="multipart/form-data" action="">
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
id="image"
|
||||||
|
type="file"
|
||||||
|
name="image[]"
|
||||||
|
accept="image/*,*.png,*.jpg,*.jpeg,*.jpx"
|
||||||
|
capture="environment"
|
||||||
|
multiple
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="grid" id="previews">
|
||||||
|
</div>
|
||||||
|
<div><input type="reset"><input type="submit" /></div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const image = document.getElementById("image");
|
||||||
|
const previews = document.getElementById("previews");
|
||||||
|
|
||||||
|
const clearPreviews = () => {
|
||||||
|
while (previews.children.length > 0) {
|
||||||
|
previews.removeChild(previews.children[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const setPreviews = () => {
|
||||||
|
for (const i of image.files) {
|
||||||
|
const el = document.createElement("div");
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.src = URL.createObjectURL(i);
|
||||||
|
el.appendChild(img);
|
||||||
|
const txt = document.createElement("input");
|
||||||
|
txt.name = "notes[]";
|
||||||
|
txt.placeholder = "Notes ...";
|
||||||
|
el.appendChild(txt);
|
||||||
|
previews.appendChild(el);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
image.addEventListener("change", (e) => {
|
||||||
|
clearPreviews();
|
||||||
|
setPreviews();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.forms[0].addEventListener("reset", () => {
|
||||||
|
clearPreviews();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (image.files.length > 0) {
|
||||||
|
setPreviews();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -1,2 +0,0 @@
|
||||||
dist/
|
|
||||||
node_modules/
|
|
|
@ -1,16 +0,0 @@
|
||||||
export async function getResponseError(r: Response): Promise<string> {
|
|
||||||
let ct = r.headers.get("Content-Type");
|
|
||||||
if (ct && ct.indexOf("json") > -1) {
|
|
||||||
const json = await r.json();
|
|
||||||
if (json.error) {
|
|
||||||
return json.error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const html = await r.text();
|
|
||||||
if (html) {
|
|
||||||
const doc = new DOMParser().parseFromString(html, "text/html");
|
|
||||||
return doc.documentElement.textContent ?? "";
|
|
||||||
} else {
|
|
||||||
return r.statusText;
|
|
||||||
}
|
|
||||||
}
|
|
34
js/alert.ts
34
js/alert.ts
|
@ -1,34 +0,0 @@
|
||||||
import SlAlert from "@shoelace-style/shoelace/dist/components/alert/alert.js";
|
|
||||||
import SlIcon from "@shoelace-style/shoelace/dist/components/icon/icon.js";
|
|
||||||
|
|
||||||
type AlertVariant = "primary" | "success" | "neutral" | "warning" | "danger";
|
|
||||||
|
|
||||||
export function notify(
|
|
||||||
message: string,
|
|
||||||
variant: AlertVariant = "primary",
|
|
||||||
iconName = "info-circle",
|
|
||||||
duration: number | null = 3000,
|
|
||||||
) {
|
|
||||||
const alert = new SlAlert();
|
|
||||||
const icon = new SlIcon();
|
|
||||||
icon.slot = "icon";
|
|
||||||
icon.name = iconName;
|
|
||||||
alert.variant = variant;
|
|
||||||
alert.open = true;
|
|
||||||
alert.style.position = "relative";
|
|
||||||
alert.closable = true;
|
|
||||||
if (duration) {
|
|
||||||
alert.duration = duration;
|
|
||||||
}
|
|
||||||
alert.appendChild(icon);
|
|
||||||
alert.appendChild(document.createTextNode(message));
|
|
||||||
document.body.append(alert);
|
|
||||||
alert.toast();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function notifyError(
|
|
||||||
message: string,
|
|
||||||
duration: number | null = null,
|
|
||||||
) {
|
|
||||||
notify(message, "danger", "exclamation-octagon", duration);
|
|
||||||
}
|
|
58
js/build.js
58
js/build.js
|
@ -1,58 +0,0 @@
|
||||||
import * as esbuild from "esbuild";
|
|
||||||
import * as path from "path";
|
|
||||||
import * as fs from "fs";
|
|
||||||
import * as url from "url";
|
|
||||||
const __dirname = url.fileURLToPath(new URL(".", import.meta.url));
|
|
||||||
|
|
||||||
const context = await esbuild.context({
|
|
||||||
entryPoints: [
|
|
||||||
"*.ts",
|
|
||||||
"icons/**/*",
|
|
||||||
],
|
|
||||||
outdir: "./dist",
|
|
||||||
bundle: true,
|
|
||||||
sourcemap: true,
|
|
||||||
platform: "node",
|
|
||||||
target: "esnext",
|
|
||||||
format: "esm",
|
|
||||||
loader: {
|
|
||||||
".png": "copy",
|
|
||||||
".ico": "copy",
|
|
||||||
},
|
|
||||||
plugins: [
|
|
||||||
{
|
|
||||||
name: "copy-shoelace-assets",
|
|
||||||
setup(build) {
|
|
||||||
let hasCopied = false;
|
|
||||||
build.onStart(() => {
|
|
||||||
if (!hasCopied) {
|
|
||||||
// We only copy one time, these files shouldn't be changing often.
|
|
||||||
const shoelaceAssets = path.resolve(
|
|
||||||
__dirname,
|
|
||||||
"node_modules/@shoelace-style/shoelace/dist/assets",
|
|
||||||
);
|
|
||||||
const outputDir = path.resolve(
|
|
||||||
__dirname,
|
|
||||||
"dist/shoelace/assets",
|
|
||||||
);
|
|
||||||
|
|
||||||
fs.rmSync(outputDir, { force: true, recursive: true });
|
|
||||||
fs.cpSync(shoelaceAssets, outputDir, {
|
|
||||||
recursive: true,
|
|
||||||
});
|
|
||||||
hasCopied = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
await context.rebuild();
|
|
||||||
|
|
||||||
if (process.argv.includes("--watch")) {
|
|
||||||
await context.watch();
|
|
||||||
console.log("watching for file changes...");
|
|
||||||
} else {
|
|
||||||
await context.dispose();
|
|
||||||
}
|
|
177
js/camera.ts
177
js/camera.ts
|
@ -1,177 +0,0 @@
|
||||||
import "@shoelace-style/shoelace/dist/components/icon-button/icon-button.js";
|
|
||||||
import "@shoelace-style/shoelace/dist/components/tooltip/tooltip.js";
|
|
||||||
|
|
||||||
import SlIconButton from "@shoelace-style/shoelace/dist/components/icon-button/icon-button.js";
|
|
||||||
|
|
||||||
import { notifyError } from "./alert.js";
|
|
||||||
|
|
||||||
const STYLESHEET = `
|
|
||||||
.camera-input {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.camera-input video,
|
|
||||||
.camera-input canvas {
|
|
||||||
width: 100%;
|
|
||||||
height: auto;
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
|
||||||
|
|
||||||
.camera-buttons sl-icon-button {
|
|
||||||
font-size: 3em;
|
|
||||||
margin: 0 0.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export default class CameraInput extends HTMLElement {
|
|
||||||
elmVideo: HTMLVideoElement;
|
|
||||||
btnShutter: SlIconButton;
|
|
||||||
btnClear: SlIconButton;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
super();
|
|
||||||
}
|
|
||||||
|
|
||||||
connectedCallback() {
|
|
||||||
this.innerHTML = "";
|
|
||||||
const shadow = this.attachShadow({ mode: "open" });
|
|
||||||
shadow.append(
|
|
||||||
Object.assign(document.createElement("style"), {
|
|
||||||
textContent: STYLESHEET,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const wrapper = document.createElement("div");
|
|
||||||
wrapper.className = "camera-input";
|
|
||||||
shadow.appendChild(wrapper);
|
|
||||||
|
|
||||||
this.elmVideo = Object.assign(document.createElement("video"), {
|
|
||||||
className: "hidden",
|
|
||||||
});
|
|
||||||
this.elmVideo.addEventListener("canplay", () => {
|
|
||||||
this.btnShutter.disabled = false;
|
|
||||||
});
|
|
||||||
wrapper.appendChild(this.elmVideo);
|
|
||||||
|
|
||||||
const buttons = document.createElement("div");
|
|
||||||
buttons.classList.add("camera-buttons");
|
|
||||||
wrapper.appendChild(buttons);
|
|
||||||
this.btnShutter = Object.assign(
|
|
||||||
document.createElement("sl-icon-button"),
|
|
||||||
{
|
|
||||||
name: "camera",
|
|
||||||
label: "Take Photo",
|
|
||||||
disabled: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
this.btnShutter.addEventListener("click", () => {
|
|
||||||
this.takePhoto();
|
|
||||||
});
|
|
||||||
const ttShutter = Object.assign(document.createElement("sl-tooltip"), {
|
|
||||||
content: "Take Photo",
|
|
||||||
});
|
|
||||||
ttShutter.appendChild(this.btnShutter);
|
|
||||||
buttons.appendChild(ttShutter);
|
|
||||||
this.btnClear = Object.assign(
|
|
||||||
document.createElement("sl-icon-button"),
|
|
||||||
{
|
|
||||||
name: "trash",
|
|
||||||
label: "Start Over",
|
|
||||||
disabled: true,
|
|
||||||
className: "hidden",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
this.btnClear.addEventListener("click", () => {
|
|
||||||
this.clearCamera();
|
|
||||||
this.startCamera();
|
|
||||||
});
|
|
||||||
const ttClear = Object.assign(document.createElement("sl-tooltip"), {
|
|
||||||
content: "Start Over",
|
|
||||||
});
|
|
||||||
ttClear.appendChild(this.btnClear);
|
|
||||||
buttons.appendChild(ttClear);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getBlob(): Promise<Blob | null> {
|
|
||||||
const canvas = this.shadowRoot!.querySelector("canvas");
|
|
||||||
return await new Promise((resolve) => {
|
|
||||||
if (canvas) {
|
|
||||||
canvas.toBlob((blob) => {
|
|
||||||
resolve(blob);
|
|
||||||
}, "image/jpeg");
|
|
||||||
} else {
|
|
||||||
resolve(null);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async clearCamera() {
|
|
||||||
this.elmVideo.pause();
|
|
||||||
this.elmVideo.srcObject = null;
|
|
||||||
this.elmVideo.classList.add("hidden");
|
|
||||||
this.elmVideo.parentNode
|
|
||||||
?.querySelectorAll("canvas")
|
|
||||||
.forEach((e) => e.remove());
|
|
||||||
this.btnShutter.disabled = true;
|
|
||||||
this.btnShutter.classList.add("hidden");
|
|
||||||
this.btnClear.disabled = true;
|
|
||||||
this.btnClear.classList.add("hidden");
|
|
||||||
this.sendReady(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
sendReady(hasPhoto: boolean) {
|
|
||||||
this.dispatchEvent(new CustomEvent("ready", { detail: { hasPhoto } }));
|
|
||||||
}
|
|
||||||
|
|
||||||
async startCamera() {
|
|
||||||
let stream: MediaStream;
|
|
||||||
try {
|
|
||||||
stream = await navigator.mediaDevices.getUserMedia({
|
|
||||||
video: {
|
|
||||||
facingMode: {
|
|
||||||
ideal: "environment",
|
|
||||||
},
|
|
||||||
width: { ideal: 1280 },
|
|
||||||
height: { ideal: 720 },
|
|
||||||
},
|
|
||||||
audio: false,
|
|
||||||
});
|
|
||||||
} catch (ex) {
|
|
||||||
console.error(ex);
|
|
||||||
notifyError(`${ex}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.btnShutter.classList.remove("hidden");
|
|
||||||
this.elmVideo.classList.remove("hidden");
|
|
||||||
this.elmVideo.srcObject = stream;
|
|
||||||
this.elmVideo.play();
|
|
||||||
}
|
|
||||||
|
|
||||||
takePhoto() {
|
|
||||||
this.btnShutter.disabled = true;
|
|
||||||
this.btnShutter.classList.add("hidden");
|
|
||||||
this.elmVideo.pause();
|
|
||||||
const canvas = document.createElement("canvas");
|
|
||||||
const context = canvas.getContext("2d");
|
|
||||||
if (!context) {
|
|
||||||
notifyError("Failed to get canvas 2D rendering context");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const width = this.elmVideo.videoWidth;
|
|
||||||
const height = this.elmVideo.videoHeight;
|
|
||||||
canvas.width = width;
|
|
||||||
canvas.height = height;
|
|
||||||
context.drawImage(this.elmVideo, 0, 0, width, height);
|
|
||||||
this.elmVideo.srcObject = null;
|
|
||||||
this.elmVideo.classList.add("hidden");
|
|
||||||
this.elmVideo.after(canvas);
|
|
||||||
this.btnClear.disabled = false;
|
|
||||||
this.btnClear.classList.remove("hidden");
|
|
||||||
this.sendReady(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
customElements.define("camera-input", CameraInput);
|
|
|
@ -1,90 +0,0 @@
|
||||||
:host,
|
|
||||||
body {
|
|
||||||
font-family: var(--sl-font-sans);
|
|
||||||
color: var(--sl-color-neutral-900);
|
|
||||||
}
|
|
||||||
|
|
||||||
body:has(div#page-loading) main {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
main {
|
|
||||||
transition: 1s opacity;
|
|
||||||
}
|
|
||||||
|
|
||||||
#page-loading {
|
|
||||||
animation: 2s linear infinite pulse;
|
|
||||||
align-items: center;
|
|
||||||
bottom: 0;
|
|
||||||
display: flex;
|
|
||||||
font-size: 24pt;
|
|
||||||
justify-content: center;
|
|
||||||
left: 0;
|
|
||||||
position: absolute;
|
|
||||||
right: 0;
|
|
||||||
top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
0% {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (min-width: 900px) {
|
|
||||||
body {
|
|
||||||
margin: auto;
|
|
||||||
max-width: 1000px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
form footer {
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
form footer sl-button {
|
|
||||||
margin: 0.5em 0.25em;
|
|
||||||
}
|
|
||||||
|
|
||||||
table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
}
|
|
||||||
|
|
||||||
tr {
|
|
||||||
border-bottom: 1px solid var(--sl-color-neutral-200);
|
|
||||||
}
|
|
||||||
|
|
||||||
table td,
|
|
||||||
table th {
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
col.shrink {
|
|
||||||
text-align: center;
|
|
||||||
width: 1px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-actions {
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
sl-input[data-user-invalid]::part(base),
|
|
||||||
sl-select[data-user-invalid]::part(combobox),
|
|
||||||
sl-checkbox[data-user-invalid]::part(control) {
|
|
||||||
border-color: var(--sl-color-danger-600);
|
|
||||||
}
|
|
||||||
|
|
||||||
sl-input:focus-within[data-user-invalid]::part(base),
|
|
||||||
sl-select:focus-within[data-user-invalid]::part(combobox),
|
|
||||||
sl-checkbox:focus-within[data-user-invalid]::part(control) {
|
|
||||||
border-color: var(--sl-color-danger-600);
|
|
||||||
box-shadow: 0 0 0 var(--sl-focus-ring-width) var(--sl-color-danger-300);
|
|
||||||
}
|
|
20
js/common.ts
20
js/common.ts
|
@ -1,20 +0,0 @@
|
||||||
import "@shoelace-style/shoelace/dist/themes/light.css";
|
|
||||||
import "@shoelace-style/shoelace/dist/themes/dark.css";
|
|
||||||
|
|
||||||
import "./common.css";
|
|
||||||
|
|
||||||
const mql = window.matchMedia("(prefers-color-scheme: dark");
|
|
||||||
const setDarkMode = () => {
|
|
||||||
if (mql.matches) {
|
|
||||||
document.documentElement.classList.add("sl-theme-dark");
|
|
||||||
} else {
|
|
||||||
document.documentElement.classList.remove("sl-theme-dark");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
mql.addEventListener("change", setDarkMode);
|
|
||||||
setDarkMode();
|
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
|
||||||
const elm = document.getElementById("page-loading");
|
|
||||||
if (elm) elm.remove();
|
|
||||||
});
|
|
Binary file not shown.
Before Width: | Height: | Size: 2.5 KiB |
Binary file not shown.
Before Width: | Height: | Size: 5.1 KiB |
Binary file not shown.
Before Width: | Height: | Size: 3.0 KiB |
Binary file not shown.
Before Width: | Height: | Size: 5.6 KiB |
Binary file not shown.
Before Width: | Height: | Size: 9.2 KiB |
Binary file not shown.
Before Width: | Height: | Size: 17 KiB |
|
@ -1,648 +0,0 @@
|
||||||
{
|
|
||||||
"name": "receipts",
|
|
||||||
"lockfileVersion": 3,
|
|
||||||
"requires": true,
|
|
||||||
"packages": {
|
|
||||||
"": {
|
|
||||||
"name": "receipts",
|
|
||||||
"dependencies": {
|
|
||||||
"@shoelace-style/shoelace": "^2.20.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"esbuild": "^0.25.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@ctrl/tinycolor": {
|
|
||||||
"version": "4.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.1.0.tgz",
|
|
||||||
"integrity": "sha512-WyOx8cJQ+FQus4Mm4uPIZA64gbk3Wxh0so5Lcii0aJifqwoVOlfFtorjLE0Hen4OYyHZMXDWqMmaQemBhgxFRQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
|
||||||
"version": "0.25.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz",
|
|
||||||
"integrity": "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==",
|
|
||||||
"cpu": [
|
|
||||||
"ppc64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"aix"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/android-arm": {
|
|
||||||
"version": "0.25.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.0.tgz",
|
|
||||||
"integrity": "sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/android-arm64": {
|
|
||||||
"version": "0.25.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.0.tgz",
|
|
||||||
"integrity": "sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/android-x64": {
|
|
||||||
"version": "0.25.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.0.tgz",
|
|
||||||
"integrity": "sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/darwin-arm64": {
|
|
||||||
"version": "0.25.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.0.tgz",
|
|
||||||
"integrity": "sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/darwin-x64": {
|
|
||||||
"version": "0.25.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.0.tgz",
|
|
||||||
"integrity": "sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/freebsd-arm64": {
|
|
||||||
"version": "0.25.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.0.tgz",
|
|
||||||
"integrity": "sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"freebsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/freebsd-x64": {
|
|
||||||
"version": "0.25.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.0.tgz",
|
|
||||||
"integrity": "sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"freebsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-arm": {
|
|
||||||
"version": "0.25.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.0.tgz",
|
|
||||||
"integrity": "sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-arm64": {
|
|
||||||
"version": "0.25.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.0.tgz",
|
|
||||||
"integrity": "sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-ia32": {
|
|
||||||
"version": "0.25.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.0.tgz",
|
|
||||||
"integrity": "sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==",
|
|
||||||
"cpu": [
|
|
||||||
"ia32"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-loong64": {
|
|
||||||
"version": "0.25.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.0.tgz",
|
|
||||||
"integrity": "sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==",
|
|
||||||
"cpu": [
|
|
||||||
"loong64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-mips64el": {
|
|
||||||
"version": "0.25.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.0.tgz",
|
|
||||||
"integrity": "sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==",
|
|
||||||
"cpu": [
|
|
||||||
"mips64el"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-ppc64": {
|
|
||||||
"version": "0.25.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.0.tgz",
|
|
||||||
"integrity": "sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==",
|
|
||||||
"cpu": [
|
|
||||||
"ppc64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-riscv64": {
|
|
||||||
"version": "0.25.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.0.tgz",
|
|
||||||
"integrity": "sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==",
|
|
||||||
"cpu": [
|
|
||||||
"riscv64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-s390x": {
|
|
||||||
"version": "0.25.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.0.tgz",
|
|
||||||
"integrity": "sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==",
|
|
||||||
"cpu": [
|
|
||||||
"s390x"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-x64": {
|
|
||||||
"version": "0.25.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.0.tgz",
|
|
||||||
"integrity": "sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/netbsd-arm64": {
|
|
||||||
"version": "0.25.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.0.tgz",
|
|
||||||
"integrity": "sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"netbsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/netbsd-x64": {
|
|
||||||
"version": "0.25.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.0.tgz",
|
|
||||||
"integrity": "sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"netbsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/openbsd-arm64": {
|
|
||||||
"version": "0.25.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.0.tgz",
|
|
||||||
"integrity": "sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"openbsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/openbsd-x64": {
|
|
||||||
"version": "0.25.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.0.tgz",
|
|
||||||
"integrity": "sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"openbsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/sunos-x64": {
|
|
||||||
"version": "0.25.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.0.tgz",
|
|
||||||
"integrity": "sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"sunos"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/win32-arm64": {
|
|
||||||
"version": "0.25.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.0.tgz",
|
|
||||||
"integrity": "sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/win32-ia32": {
|
|
||||||
"version": "0.25.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.0.tgz",
|
|
||||||
"integrity": "sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==",
|
|
||||||
"cpu": [
|
|
||||||
"ia32"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/win32-x64": {
|
|
||||||
"version": "0.25.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.0.tgz",
|
|
||||||
"integrity": "sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@floating-ui/core": {
|
|
||||||
"version": "1.6.9",
|
|
||||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.9.tgz",
|
|
||||||
"integrity": "sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@floating-ui/utils": "^0.2.9"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@floating-ui/dom": {
|
|
||||||
"version": "1.6.13",
|
|
||||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.13.tgz",
|
|
||||||
"integrity": "sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@floating-ui/core": "^1.6.0",
|
|
||||||
"@floating-ui/utils": "^0.2.9"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@floating-ui/utils": {
|
|
||||||
"version": "0.2.9",
|
|
||||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz",
|
|
||||||
"integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@lit-labs/ssr-dom-shim": {
|
|
||||||
"version": "1.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.3.0.tgz",
|
|
||||||
"integrity": "sha512-nQIWonJ6eFAvUUrSlwyHDm/aE8PBDu5kRpL0vHMg6K8fK3Diq1xdPjTnsJSwxABhaZ+5eBi1btQB5ShUTKo4nQ==",
|
|
||||||
"license": "BSD-3-Clause"
|
|
||||||
},
|
|
||||||
"node_modules/@lit/react": {
|
|
||||||
"version": "1.0.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@lit/react/-/react-1.0.7.tgz",
|
|
||||||
"integrity": "sha512-cencnwwLXQKiKxjfFzSgZRngcWJzUDZi/04E0fSaF86wZgchMdvTyu+lE36DrUfvuus3bH8+xLPrhM1cTjwpzw==",
|
|
||||||
"license": "BSD-3-Clause",
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/react": "17 || 18 || 19"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@lit/reactive-element": {
|
|
||||||
"version": "2.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-2.0.4.tgz",
|
|
||||||
"integrity": "sha512-GFn91inaUa2oHLak8awSIigYz0cU0Payr1rcFsrkf5OJ5eSPxElyZfKh0f2p9FsTiZWXQdWGJeXZICEfXXYSXQ==",
|
|
||||||
"license": "BSD-3-Clause",
|
|
||||||
"dependencies": {
|
|
||||||
"@lit-labs/ssr-dom-shim": "^1.2.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@shoelace-style/animations": {
|
|
||||||
"version": "1.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@shoelace-style/animations/-/animations-1.2.0.tgz",
|
|
||||||
"integrity": "sha512-avvo1xxkLbv2dgtabdewBbqcJfV0e0zCwFqkPMnHFGbJbBHorRFfMAHh1NG9ymmXn0jW95ibUVH03E1NYXD6Gw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"type": "individual",
|
|
||||||
"url": "https://github.com/sponsors/claviska"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@shoelace-style/localize": {
|
|
||||||
"version": "3.2.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@shoelace-style/localize/-/localize-3.2.1.tgz",
|
|
||||||
"integrity": "sha512-r4C9C/5kSfMBIr0D9imvpRdCNXtUNgyYThc4YlS6K5Hchv1UyxNQ9mxwj+BTRH2i1Neits260sR3OjKMnplsFA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@shoelace-style/shoelace": {
|
|
||||||
"version": "2.20.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@shoelace-style/shoelace/-/shoelace-2.20.0.tgz",
|
|
||||||
"integrity": "sha512-Qq/kPtWC//HVyHX6EZ/i5y9zTMORjqV4lrxU2sbHLh+qdc9DlroYVSSqa2eqkmSzeLO+gHPZrjYmxDTja85iAA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@ctrl/tinycolor": "^4.1.0",
|
|
||||||
"@floating-ui/dom": "^1.6.12",
|
|
||||||
"@lit/react": "^1.0.6",
|
|
||||||
"@shoelace-style/animations": "^1.2.0",
|
|
||||||
"@shoelace-style/localize": "^3.2.1",
|
|
||||||
"composed-offset-position": "^0.0.6",
|
|
||||||
"lit": "^3.2.1",
|
|
||||||
"qr-creator": "^1.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.17.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "individual",
|
|
||||||
"url": "https://github.com/sponsors/claviska"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/react": {
|
|
||||||
"version": "19.0.10",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.10.tgz",
|
|
||||||
"integrity": "sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"csstype": "^3.0.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/trusted-types": {
|
|
||||||
"version": "2.0.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
|
||||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/composed-offset-position": {
|
|
||||||
"version": "0.0.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/composed-offset-position/-/composed-offset-position-0.0.6.tgz",
|
|
||||||
"integrity": "sha512-Q7dLompI6lUwd7LWyIcP66r4WcS9u7AL2h8HaeipiRfCRPLMWqRx8fYsjb4OHi6UQFifO7XtNC2IlEJ1ozIFxw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"@floating-ui/utils": "^0.2.5"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/csstype": {
|
|
||||||
"version": "3.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
|
||||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true
|
|
||||||
},
|
|
||||||
"node_modules/esbuild": {
|
|
||||||
"version": "0.25.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz",
|
|
||||||
"integrity": "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==",
|
|
||||||
"dev": true,
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"bin": {
|
|
||||||
"esbuild": "bin/esbuild"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@esbuild/aix-ppc64": "0.25.0",
|
|
||||||
"@esbuild/android-arm": "0.25.0",
|
|
||||||
"@esbuild/android-arm64": "0.25.0",
|
|
||||||
"@esbuild/android-x64": "0.25.0",
|
|
||||||
"@esbuild/darwin-arm64": "0.25.0",
|
|
||||||
"@esbuild/darwin-x64": "0.25.0",
|
|
||||||
"@esbuild/freebsd-arm64": "0.25.0",
|
|
||||||
"@esbuild/freebsd-x64": "0.25.0",
|
|
||||||
"@esbuild/linux-arm": "0.25.0",
|
|
||||||
"@esbuild/linux-arm64": "0.25.0",
|
|
||||||
"@esbuild/linux-ia32": "0.25.0",
|
|
||||||
"@esbuild/linux-loong64": "0.25.0",
|
|
||||||
"@esbuild/linux-mips64el": "0.25.0",
|
|
||||||
"@esbuild/linux-ppc64": "0.25.0",
|
|
||||||
"@esbuild/linux-riscv64": "0.25.0",
|
|
||||||
"@esbuild/linux-s390x": "0.25.0",
|
|
||||||
"@esbuild/linux-x64": "0.25.0",
|
|
||||||
"@esbuild/netbsd-arm64": "0.25.0",
|
|
||||||
"@esbuild/netbsd-x64": "0.25.0",
|
|
||||||
"@esbuild/openbsd-arm64": "0.25.0",
|
|
||||||
"@esbuild/openbsd-x64": "0.25.0",
|
|
||||||
"@esbuild/sunos-x64": "0.25.0",
|
|
||||||
"@esbuild/win32-arm64": "0.25.0",
|
|
||||||
"@esbuild/win32-ia32": "0.25.0",
|
|
||||||
"@esbuild/win32-x64": "0.25.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lit": {
|
|
||||||
"version": "3.2.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/lit/-/lit-3.2.1.tgz",
|
|
||||||
"integrity": "sha512-1BBa1E/z0O9ye5fZprPtdqnc0BFzxIxTTOO/tQFmyC/hj1O3jL4TfmLBw0WEwjAokdLwpclkvGgDJwTIh0/22w==",
|
|
||||||
"license": "BSD-3-Clause",
|
|
||||||
"dependencies": {
|
|
||||||
"@lit/reactive-element": "^2.0.4",
|
|
||||||
"lit-element": "^4.1.0",
|
|
||||||
"lit-html": "^3.2.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lit-element": {
|
|
||||||
"version": "4.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/lit-element/-/lit-element-4.1.1.tgz",
|
|
||||||
"integrity": "sha512-HO9Tkkh34QkTeUmEdNYhMT8hzLid7YlMlATSi1q4q17HE5d9mrrEHJ/o8O2D0cMi182zK1F3v7x0PWFjrhXFew==",
|
|
||||||
"license": "BSD-3-Clause",
|
|
||||||
"dependencies": {
|
|
||||||
"@lit-labs/ssr-dom-shim": "^1.2.0",
|
|
||||||
"@lit/reactive-element": "^2.0.4",
|
|
||||||
"lit-html": "^3.2.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lit-html": {
|
|
||||||
"version": "3.2.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.2.1.tgz",
|
|
||||||
"integrity": "sha512-qI/3lziaPMSKsrwlxH/xMgikhQ0EGOX2ICU73Bi/YHFvz2j/yMCIrw4+puF2IpQ4+upd3EWbvnHM9+PnJn48YA==",
|
|
||||||
"license": "BSD-3-Clause",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/trusted-types": "^2.0.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/qr-creator": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/qr-creator/-/qr-creator-1.0.0.tgz",
|
|
||||||
"integrity": "sha512-C0cqfbS1P5hfqN4NhsYsUXePlk9BO+a45bAQ3xLYjBL3bOIFzoVEjs79Fado9u9BPBD3buHi3+vY+C8tHh4qMQ==",
|
|
||||||
"license": "MIT"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,13 +0,0 @@
|
||||||
{
|
|
||||||
"name": "receipts",
|
|
||||||
"type": "module",
|
|
||||||
"dependencies": {
|
|
||||||
"@shoelace-style/shoelace": "^2.20.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"esbuild": "^0.25.0"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"build": "node build.js"
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,176 +0,0 @@
|
||||||
import "@shoelace-style/shoelace/dist/components/button/button.js";
|
|
||||||
import "@shoelace-style/shoelace/dist/components/details/details.js";
|
|
||||||
import "@shoelace-style/shoelace/dist/components/icon/icon.js";
|
|
||||||
import "@shoelace-style/shoelace/dist/components/input/input.js";
|
|
||||||
import "@shoelace-style/shoelace/dist/components/option/option.js";
|
|
||||||
import "@shoelace-style/shoelace/dist/components/spinner/spinner.js";
|
|
||||||
import "@shoelace-style/shoelace/dist/components/select/select.js";
|
|
||||||
import "@shoelace-style/shoelace/dist/components/switch/switch.js";
|
|
||||||
import "@shoelace-style/shoelace/dist/components/textarea/textarea.js";
|
|
||||||
|
|
||||||
import "./shoelace.js";
|
|
||||||
|
|
||||||
import "./camera.ts";
|
|
||||||
|
|
||||||
import CameraInput from "./camera.ts";
|
|
||||||
import SlButton from "@shoelace-style/shoelace/dist/components/button/button.js";
|
|
||||||
import SlSelect from "@shoelace-style/shoelace/dist/components/select/select.js";
|
|
||||||
import SlSwitch from "@shoelace-style/shoelace/dist/components/switch/switch.js";
|
|
||||||
|
|
||||||
import { notify, notifyError } from "./alert";
|
|
||||||
import { getResponseError } from "./ajaxUtil.js";
|
|
||||||
|
|
||||||
const form = document.forms[0];
|
|
||||||
const cameraInput = form.querySelector("camera-input") as CameraInput;
|
|
||||||
const btnSubmit = form.querySelector("sl-button[type='submit']") as SlButton;
|
|
||||||
const btnUpload = form.querySelector(
|
|
||||||
"sl-button[class='choose-file']",
|
|
||||||
) as SlButton;
|
|
||||||
const inpImage = form.photo as HTMLInputElement;
|
|
||||||
const imgPreview = document.getElementById(
|
|
||||||
"upload-preview",
|
|
||||||
) as HTMLImageElement;
|
|
||||||
const xactselect = document.getElementById("transactions") as SlSelect;
|
|
||||||
|
|
||||||
let dirty = false;
|
|
||||||
|
|
||||||
window.addEventListener("beforeunload", function (evt) {
|
|
||||||
if (dirty) {
|
|
||||||
evt.preventDefault();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
form.querySelectorAll("sl-input, sl-textarea, input").forEach((inp) => {
|
|
||||||
let eventName = inp.tagName == "input" ? "cange" : "sl-change";
|
|
||||||
inp.addEventListener(eventName, (evt) => {
|
|
||||||
if ((evt.target as HTMLInputElement).value) {
|
|
||||||
dirty = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
form.addEventListener("submit", async (evt) => {
|
|
||||||
evt.preventDefault();
|
|
||||||
btnSubmit.loading = true;
|
|
||||||
const data = new FormData(form);
|
|
||||||
if (!inpImage.files?.length) {
|
|
||||||
data.delete("photo");
|
|
||||||
const blob = await cameraInput.getBlob();
|
|
||||||
if (blob) {
|
|
||||||
data.append("photo", blob, "photo.jpg");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let r: Response;
|
|
||||||
try {
|
|
||||||
r = await fetch("", {
|
|
||||||
method: "POST",
|
|
||||||
body: data,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
notifyError(`Failed to submit form: ${e}`);
|
|
||||||
return;
|
|
||||||
} finally {
|
|
||||||
btnSubmit.loading = false;
|
|
||||||
}
|
|
||||||
if (r.ok) {
|
|
||||||
notify("Successfully uploaded receipt", undefined, undefined, null);
|
|
||||||
dirty = false;
|
|
||||||
window.location.href = "/receipts";
|
|
||||||
} else {
|
|
||||||
const err = await getResponseError(r);
|
|
||||||
notifyError(err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
form.addEventListener("reset", () => {
|
|
||||||
dirty = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
cameraInput.addEventListener("ready", ((evt: CustomEvent) => {
|
|
||||||
btnSubmit.disabled = !evt.detail.hasPhoto;
|
|
||||||
btnUpload.disabled = !!evt.detail.hasPhoto;
|
|
||||||
if (!!evt.detail.hasPhoto) {
|
|
||||||
inpImage.value = "";
|
|
||||||
imgPreview.src = "";
|
|
||||||
}
|
|
||||||
}) as EventListener);
|
|
||||||
|
|
||||||
const cameraDetails = document.querySelector(
|
|
||||||
"sl-details[summary='Take Photo']",
|
|
||||||
)!;
|
|
||||||
let cameraInitialized = false;
|
|
||||||
cameraDetails.addEventListener("sl-show", () => {
|
|
||||||
if (!cameraInitialized) {
|
|
||||||
cameraInitialized = true;
|
|
||||||
cameraInput.startCamera();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
btnUpload.addEventListener("click", (evt) => {
|
|
||||||
evt.preventDefault();
|
|
||||||
form.photo.showPicker();
|
|
||||||
});
|
|
||||||
|
|
||||||
inpImage.addEventListener("change", () => {
|
|
||||||
if (inpImage.files) {
|
|
||||||
const file = inpImage.files[0];
|
|
||||||
if (file) {
|
|
||||||
btnSubmit.disabled = false;
|
|
||||||
imgPreview.src = URL.createObjectURL(file);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function fetchTransactions() {
|
|
||||||
let r: Response;
|
|
||||||
try {
|
|
||||||
r = await fetch("/transactions");
|
|
||||||
} catch (e) {
|
|
||||||
notifyError(e.toString());
|
|
||||||
return;
|
|
||||||
} finally {
|
|
||||||
xactselect.placeholder = "";
|
|
||||||
xactselect.querySelectorAll("sl-spinner").forEach((e) => e.remove());
|
|
||||||
}
|
|
||||||
if (!r.ok) {
|
|
||||||
notifyError(await getResponseError(r), 10000);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
xactselect.placeholder = "Select existing transaction";
|
|
||||||
let prev = xactselect.firstChild;
|
|
||||||
for (const xact of await r.json()) {
|
|
||||||
const option = document.createElement("sl-option");
|
|
||||||
option.value = xact.id;
|
|
||||||
option.textContent = `${xact.description} • ${xact.date} • $${xact.amount}`;
|
|
||||||
option.dataset.amount = xact.amount;
|
|
||||||
option.dataset.date = xact.date.split("T")[0];
|
|
||||||
option.dataset.vendor = xact.description;
|
|
||||||
option.dataset.is_restaurant = xact.is_restaurant;
|
|
||||||
xactselect.insertBefore(option, prev);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
xactselect.addEventListener("sl-change", () => {
|
|
||||||
const value = xactselect.value;
|
|
||||||
if (!value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const option: HTMLOptionElement | null = xactselect.querySelector(
|
|
||||||
`[value="${value}"]`,
|
|
||||||
);
|
|
||||||
if (!option) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
form.querySelectorAll("sl-input").forEach((elm) => {
|
|
||||||
if (elm.name && elm.value !== undefined) {
|
|
||||||
const value = option.dataset[elm.name];
|
|
||||||
if (value) {
|
|
||||||
elm.value = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
(form.querySelector("[name='is_restaurant']") as SlSwitch).checked =
|
|
||||||
option.dataset.is_restaurant == "true";
|
|
||||||
});
|
|
||||||
|
|
||||||
fetchTransactions();
|
|
|
@ -1,89 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
|
@ -1,6 +0,0 @@
|
||||||
import "@shoelace-style/shoelace/dist/components/button/button.js";
|
|
||||||
import "@shoelace-style/shoelace/dist/components/details/details.js";
|
|
||||||
import "@shoelace-style/shoelace/dist/components/input/input.js";
|
|
||||||
import "@shoelace-style/shoelace/dist/components/textarea/textarea.js";
|
|
||||||
|
|
||||||
import "./shoelace.js";
|
|
|
@ -1,2 +0,0 @@
|
||||||
import { setBasePath } from "@shoelace-style/shoelace/dist/utilities/base-path.js";
|
|
||||||
setBasePath("/static/shoelace/");
|
|
|
@ -1,19 +0,0 @@
|
||||||
.invisible {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
#photo-view {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
#photo-view video,
|
|
||||||
#photo-view canvas {
|
|
||||||
width: 100%;
|
|
||||||
height: auto;
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
|
||||||
|
|
||||||
#photo-view sl-icon-button {
|
|
||||||
font-size: 3em;
|
|
||||||
margin: 0 0.5em;
|
|
||||||
}
|
|
|
@ -1,183 +0,0 @@
|
||||||
import "./transaction.css";
|
|
||||||
import "@shoelace-style/shoelace/dist/components/button/button.js";
|
|
||||||
import "@shoelace-style/shoelace/dist/components/breadcrumb/breadcrumb.js";
|
|
||||||
import "@shoelace-style/shoelace/dist/components/breadcrumb-item/breadcrumb-item.js";
|
|
||||||
import "@shoelace-style/shoelace/dist/components/details/details.js";
|
|
||||||
import "@shoelace-style/shoelace/dist/components/icon-button/icon-button.js";
|
|
||||||
import "@shoelace-style/shoelace/dist/components/input/input.js";
|
|
||||||
import "@shoelace-style/shoelace/dist/components/textarea/textarea.js";
|
|
||||||
import "@shoelace-style/shoelace/dist/components/tooltip/tooltip.js";
|
|
||||||
|
|
||||||
import SlButton from "@shoelace-style/shoelace/dist/components/button/button.js";
|
|
||||||
import SlDetails from "@shoelace-style/shoelace/dist/components/details/details.js";
|
|
||||||
import SlIconButton from "@shoelace-style/shoelace/dist/components/icon-button/icon-button.js";
|
|
||||||
import { setBasePath } from "@shoelace-style/shoelace/dist/utilities/base-path.js";
|
|
||||||
|
|
||||||
import { notify } from "./alert";
|
|
||||||
|
|
||||||
setBasePath("/static/shoelace/");
|
|
||||||
|
|
||||||
const form = document.forms[0];
|
|
||||||
const photobox = document.getElementById("photo-box")!;
|
|
||||||
const photoview = document.getElementById("photo-view")!;
|
|
||||||
const video = photoview.querySelector("video")!;
|
|
||||||
const btnshutter = photoview.querySelector(
|
|
||||||
" sl-icon-button[label='Take Photo']",
|
|
||||||
) as SlIconButton;
|
|
||||||
const btnreset = photoview.querySelector(
|
|
||||||
" sl-icon-button[label='Start Over']",
|
|
||||||
) as SlIconButton;
|
|
||||||
|
|
||||||
let initialized = false;
|
|
||||||
|
|
||||||
async function clearCamera() {
|
|
||||||
video.pause();
|
|
||||||
video.srcObject = null;
|
|
||||||
video.classList.add("invisible");
|
|
||||||
video.parentNode?.querySelectorAll("canvas").forEach((e) => e.remove());
|
|
||||||
btnshutter.disabled = true;
|
|
||||||
btnshutter.classList.add("invisible");
|
|
||||||
btnreset.disabled = true;
|
|
||||||
btnreset.classList.add("invisible");
|
|
||||||
photoview.classList.remove("invisible");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startCamera() {
|
|
||||||
let stream: MediaStream;
|
|
||||||
try {
|
|
||||||
stream = await navigator.mediaDevices.getUserMedia({
|
|
||||||
video: {
|
|
||||||
facingMode: {
|
|
||||||
ideal: "environment",
|
|
||||||
},
|
|
||||||
width: { ideal: 1280 },
|
|
||||||
height: { ideal: 720 },
|
|
||||||
},
|
|
||||||
audio: false,
|
|
||||||
});
|
|
||||||
} catch (ex) {
|
|
||||||
console.error(ex);
|
|
||||||
notify(`${ex}`, "danger", "exclamation-octagon", null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
photobox.querySelectorAll(".fallback").forEach((e) => e.remove());
|
|
||||||
btnshutter.classList.remove("invisible");
|
|
||||||
video.classList.remove("invisible");
|
|
||||||
video.srcObject = stream;
|
|
||||||
video.play();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitForm(data: FormData) {
|
|
||||||
const btn = form.querySelector("sl-button[type='submit']") as SlButton;
|
|
||||||
btn.loading = true;
|
|
||||||
let r: Response | null = null;
|
|
||||||
try {
|
|
||||||
r = await fetch("", {
|
|
||||||
method: "POST",
|
|
||||||
body: data,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
notify(
|
|
||||||
`Failed to submit form: ${e}`,
|
|
||||||
"danger",
|
|
||||||
"exclamation-octagon",
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
btn.loading = false;
|
|
||||||
if (r) {
|
|
||||||
if (r.ok) {
|
|
||||||
notify(
|
|
||||||
"Successfully updated transaction",
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
window.location.href = "/transactions";
|
|
||||||
} else {
|
|
||||||
const html = await r.text();
|
|
||||||
if (html) {
|
|
||||||
const doc = new DOMParser().parseFromString(html, "text/html");
|
|
||||||
notify(
|
|
||||||
doc.body.textContent ?? "",
|
|
||||||
"danger",
|
|
||||||
"exclamation-octagon",
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
notify(r.statusText, "danger", "exclamation-octagon", null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function takePhoto() {
|
|
||||||
btnshutter.disabled = true;
|
|
||||||
btnshutter.classList.add("invisible");
|
|
||||||
video.pause();
|
|
||||||
const canvas = document.createElement("canvas");
|
|
||||||
canvas.setAttribute("id", "camera-photo");
|
|
||||||
const context = canvas.getContext("2d");
|
|
||||||
if (!context) {
|
|
||||||
notify(
|
|
||||||
"Failed to get canvas 2D rendering context",
|
|
||||||
"danger",
|
|
||||||
"exclamation-octagon",
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const width = video.videoWidth;
|
|
||||||
const height = video.videoHeight;
|
|
||||||
canvas.width = width;
|
|
||||||
canvas.height = height;
|
|
||||||
context.drawImage(video, 0, 0, width, height);
|
|
||||||
video.srcObject = null;
|
|
||||||
video.classList.add("invisible");
|
|
||||||
video.parentNode!.appendChild(canvas);
|
|
||||||
btnreset.disabled = false;
|
|
||||||
btnreset.classList.remove("invisible");
|
|
||||||
}
|
|
||||||
|
|
||||||
photobox.addEventListener("sl-show", () => {
|
|
||||||
if (!initialized) {
|
|
||||||
initialized = true;
|
|
||||||
clearCamera();
|
|
||||||
startCamera();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
video.addEventListener("canplay", () => {
|
|
||||||
btnshutter.disabled = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
btnshutter.addEventListener("click", async () => {
|
|
||||||
takePhoto();
|
|
||||||
});
|
|
||||||
|
|
||||||
btnreset.addEventListener("click", () => {
|
|
||||||
clearCamera();
|
|
||||||
startCamera();
|
|
||||||
});
|
|
||||||
|
|
||||||
form.addEventListener("submit", async (evt) => {
|
|
||||||
evt.preventDefault();
|
|
||||||
let data = new FormData(form);
|
|
||||||
const canvas = document.getElementById("camera-photo") as HTMLCanvasElement;
|
|
||||||
if (canvas) {
|
|
||||||
canvas.toBlob((blob: Blob | null) => {
|
|
||||||
if (blob) {
|
|
||||||
data.append("photo", blob, "photo.jpg");
|
|
||||||
}
|
|
||||||
submitForm(data);
|
|
||||||
}, "image/jpeg");
|
|
||||||
} else {
|
|
||||||
submitForm(data);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
form.addEventListener("reset", () => {
|
|
||||||
document.querySelectorAll("sl-details").forEach((e: SlDetails) => e.hide());
|
|
||||||
clearCamera();
|
|
||||||
initialized = false;
|
|
||||||
});
|
|
|
@ -1,9 +0,0 @@
|
||||||
CREATE TABLE receipts (
|
|
||||||
id serial PRIMARY KEY,
|
|
||||||
date timestamp with time zone NOT NULL,
|
|
||||||
vendor varchar(99) NOT NULL,
|
|
||||||
amount decimal NOT NULL,
|
|
||||||
notes text,
|
|
||||||
filename varchar(199) NOT NULL,
|
|
||||||
image bytea NOT NULL
|
|
||||||
);
|
|
|
@ -1,2 +1 @@
|
||||||
match_block_trailing_comma = true
|
|
||||||
max_width = 79
|
max_width = 79
|
||||||
|
|
|
@ -1,4 +0,0 @@
|
||||||
SELECT
|
|
||||||
count(id) AS "count!"
|
|
||||||
FROM
|
|
||||||
receipts
|
|
|
@ -1,6 +0,0 @@
|
||||||
SELECT
|
|
||||||
id, vendor, date, amount, notes, filename
|
|
||||||
FROM
|
|
||||||
receipts
|
|
||||||
WHERE
|
|
||||||
id = $1
|
|
|
@ -1,7 +0,0 @@
|
||||||
INSERT INTO receipts (
|
|
||||||
vendor, date, amount, notes, filename, image
|
|
||||||
) VALUES (
|
|
||||||
$1, $2, $3, $4, $5, $6
|
|
||||||
)
|
|
||||||
RETURNING
|
|
||||||
id, vendor, date, amount, notes, filename
|
|
|
@ -1,9 +0,0 @@
|
||||||
SELECT
|
|
||||||
id, vendor, date, amount, notes, filename
|
|
||||||
FROM
|
|
||||||
receipts
|
|
||||||
ORDER BY
|
|
||||||
date DESC,
|
|
||||||
id DESC
|
|
||||||
LIMIT $1
|
|
||||||
OFFSET $2
|
|
|
@ -1,22 +0,0 @@
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
use serde::Deserialize;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct FireflyConfig {
|
|
||||||
pub url: String,
|
|
||||||
pub token: PathBuf,
|
|
||||||
pub search_query: String,
|
|
||||||
pub default_account: String,
|
|
||||||
#[serde(default = "default_restaurant_tag")]
|
|
||||||
pub restaurant_tag: String
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct Config {
|
|
||||||
pub firefly: FireflyConfig,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_restaurant_tag() -> String {
|
|
||||||
"Food & Drink".into()
|
|
||||||
}
|
|
322
src/firefly.rs
322
src/firefly.rs
|
@ -1,322 +0,0 @@
|
||||||
use chrono::{DateTime, FixedOffset};
|
|
||||||
use reqwest::header::{HeaderMap, HeaderValue};
|
|
||||||
use reqwest::Client;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use tracing::{error, info};
|
|
||||||
|
|
||||||
pub struct Firefly {
|
|
||||||
url: String,
|
|
||||||
client: reqwest::Client,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Serialize, Deserialize)]
|
|
||||||
pub struct TransactionSplit {
|
|
||||||
pub transaction_journal_id: String,
|
|
||||||
pub date: chrono::DateTime<chrono::FixedOffset>,
|
|
||||||
pub amount: String,
|
|
||||||
pub description: String,
|
|
||||||
pub notes: Option<String>,
|
|
||||||
pub tags: Option<Vec<String>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Deserialize)]
|
|
||||||
pub struct TransactionAttributes {
|
|
||||||
pub group_title: Option<String>,
|
|
||||||
pub transactions: Vec<TransactionSplit>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Deserialize)]
|
|
||||||
pub struct TransactionRead {
|
|
||||||
pub id: String,
|
|
||||||
pub attributes: TransactionAttributes,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct TransactionArray {
|
|
||||||
pub data: Vec<TransactionRead>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Deserialize)]
|
|
||||||
pub struct TransactionSingle {
|
|
||||||
pub data: TransactionRead,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
pub struct TransactionSplitUpdate {
|
|
||||||
pub transaction_journal_id: String,
|
|
||||||
pub amount: String,
|
|
||||||
pub description: String,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub notes: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub tags: Option<Vec<String>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
pub struct TransactionUpdate {
|
|
||||||
pub transactions: Vec<TransactionSplitUpdate>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
#[serde(rename_all = "lowercase")]
|
|
||||||
pub enum TransactionTypeProperty {
|
|
||||||
Withdrawal,
|
|
||||||
Deposit,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
pub struct TransactionSplitStore {
|
|
||||||
#[serde(rename = "type")]
|
|
||||||
pub type_: TransactionTypeProperty,
|
|
||||||
pub date: DateTime<FixedOffset>,
|
|
||||||
pub amount: String,
|
|
||||||
pub description: String,
|
|
||||||
pub notes: Option<String>,
|
|
||||||
pub source_name: Option<String>,
|
|
||||||
pub destination_name: Option<String>,
|
|
||||||
pub tags: Option<Vec<String>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
pub struct TransactionStore {
|
|
||||||
pub error_if_duplicate_hash: bool,
|
|
||||||
pub apply_rules: bool,
|
|
||||||
pub fire_webhooks: bool,
|
|
||||||
pub group_title: String,
|
|
||||||
pub transactions: Vec<TransactionSplitStore>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
#[non_exhaustive]
|
|
||||||
enum AttachableType {
|
|
||||||
TransactionJournal,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct AttachmentStore {
|
|
||||||
filename: String,
|
|
||||||
attachable_type: AttachableType,
|
|
||||||
attachable_id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct AttachmentRead {
|
|
||||||
id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct AttachmentSingle {
|
|
||||||
data: AttachmentRead,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TransactionRead {
|
|
||||||
pub fn amount(&self) -> f64 {
|
|
||||||
self.attributes
|
|
||||||
.transactions
|
|
||||||
.iter()
|
|
||||||
.filter_map(|t| match t.amount.parse::<f64>() {
|
|
||||||
Ok(v) => Some(v),
|
|
||||||
Err(e) => {
|
|
||||||
error!("Invalid amount: {}", e);
|
|
||||||
None
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.sum()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<TransactionRead> for TransactionUpdate {
|
|
||||||
fn from(t: TransactionRead) -> Self {
|
|
||||||
let transactions: Vec<_> = t
|
|
||||||
.attributes
|
|
||||||
.transactions
|
|
||||||
.into_iter()
|
|
||||||
.map(|s| TransactionSplitUpdate {
|
|
||||||
transaction_journal_id: s.transaction_journal_id,
|
|
||||||
amount: s.amount,
|
|
||||||
description: s.description,
|
|
||||||
notes: s.notes,
|
|
||||||
tags: s.tags,
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
TransactionUpdate { transactions }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TransactionStore {
|
|
||||||
pub fn new_deposit<A, D, S, T, N, G>(
|
|
||||||
date: DateTime<FixedOffset>,
|
|
||||||
amount: A,
|
|
||||||
description: D,
|
|
||||||
source_account: S,
|
|
||||||
destination_account: T,
|
|
||||||
notes: N,
|
|
||||||
tags: G,
|
|
||||||
) -> Self
|
|
||||||
where
|
|
||||||
A: Into<String>,
|
|
||||||
D: Into<String>,
|
|
||||||
S: Into<Option<String>>,
|
|
||||||
T: Into<Option<String>>,
|
|
||||||
N: Into<Option<String>>,
|
|
||||||
G: Into<Option<Vec<String>>>,
|
|
||||||
{
|
|
||||||
Self {
|
|
||||||
error_if_duplicate_hash: true,
|
|
||||||
apply_rules: true,
|
|
||||||
fire_webhooks: true,
|
|
||||||
group_title: Default::default(),
|
|
||||||
transactions: vec![TransactionSplitStore {
|
|
||||||
type_: TransactionTypeProperty::Deposit,
|
|
||||||
date,
|
|
||||||
amount: amount.into(),
|
|
||||||
description: description.into(),
|
|
||||||
source_name: source_account.into(),
|
|
||||||
destination_name: destination_account.into(),
|
|
||||||
notes: notes.into(),
|
|
||||||
tags: tags.into(),
|
|
||||||
}],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pub fn new_withdrawal<A, D, S, T, N, G>(
|
|
||||||
date: DateTime<FixedOffset>,
|
|
||||||
amount: A,
|
|
||||||
description: D,
|
|
||||||
source_account: S,
|
|
||||||
destination_account: T,
|
|
||||||
notes: N,
|
|
||||||
tags: G,
|
|
||||||
) -> Self
|
|
||||||
where
|
|
||||||
A: Into<String>,
|
|
||||||
D: Into<String>,
|
|
||||||
S: Into<Option<String>>,
|
|
||||||
T: Into<Option<String>>,
|
|
||||||
N: Into<Option<String>>,
|
|
||||||
G: Into<Option<Vec<String>>>,
|
|
||||||
{
|
|
||||||
Self {
|
|
||||||
error_if_duplicate_hash: true,
|
|
||||||
apply_rules: true,
|
|
||||||
fire_webhooks: true,
|
|
||||||
group_title: Default::default(),
|
|
||||||
transactions: vec![TransactionSplitStore {
|
|
||||||
type_: TransactionTypeProperty::Withdrawal,
|
|
||||||
date,
|
|
||||||
amount: amount.into(),
|
|
||||||
description: description.into(),
|
|
||||||
source_name: source_account.into(),
|
|
||||||
destination_name: destination_account.into(),
|
|
||||||
notes: notes.into(),
|
|
||||||
tags: tags.into(),
|
|
||||||
}],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Firefly {
|
|
||||||
pub fn new(url: impl Into<String>, token: &str) -> Self {
|
|
||||||
let mut headers = HeaderMap::new();
|
|
||||||
headers.insert(
|
|
||||||
"Accept",
|
|
||||||
HeaderValue::from_static("application/vnd.api+json"),
|
|
||||||
);
|
|
||||||
headers.insert(
|
|
||||||
"Authorization",
|
|
||||||
HeaderValue::from_str(&format!("Bearer {}", &token)).unwrap(),
|
|
||||||
);
|
|
||||||
let client =
|
|
||||||
Client::builder().default_headers(headers).build().unwrap();
|
|
||||||
Self {
|
|
||||||
url: url.into().trim_end_matches("/").into(),
|
|
||||||
client,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn url(&self) -> &str {
|
|
||||||
&self.url
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn search_transactions(
|
|
||||||
&self,
|
|
||||||
query: &str,
|
|
||||||
) -> Result<TransactionArray, reqwest::Error> {
|
|
||||||
let url = format!("{}/api/v1/search/transactions", self.url);
|
|
||||||
let res = self
|
|
||||||
.client
|
|
||||||
.get(url)
|
|
||||||
.query(&[("query", query)])
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
res.error_for_status_ref()?;
|
|
||||||
res.json().await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_transaction(
|
|
||||||
&self,
|
|
||||||
id: &str,
|
|
||||||
) -> Result<TransactionSingle, reqwest::Error> {
|
|
||||||
let url = format!("{}/api/v1/transactions/{}", self.url, id);
|
|
||||||
let res = self.client.get(url).send().await?;
|
|
||||||
res.error_for_status_ref()?;
|
|
||||||
res.json().await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn update_transaction(
|
|
||||||
&self,
|
|
||||||
id: &str,
|
|
||||||
txn: &TransactionUpdate,
|
|
||||||
) -> Result<TransactionSingle, reqwest::Error> {
|
|
||||||
let url = format!("{}/api/v1/transactions/{}", self.url, id);
|
|
||||||
let res = self.client.put(url).json(txn).send().await?;
|
|
||||||
res.error_for_status_ref()?;
|
|
||||||
info!("Successfully updated transaction {}", id);
|
|
||||||
res.json().await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn attach_receipt(
|
|
||||||
&self,
|
|
||||||
id: &str,
|
|
||||||
content: impl Into<reqwest::Body>,
|
|
||||||
filename: impl Into<String>,
|
|
||||||
) -> Result<(), reqwest::Error> {
|
|
||||||
let filename = filename.into();
|
|
||||||
info!("Attaching receipt {} to transaction {}", filename, id);
|
|
||||||
let url = format!("{}/api/v1/attachments", self.url);
|
|
||||||
let data = AttachmentStore {
|
|
||||||
filename,
|
|
||||||
attachable_type: AttachableType::TransactionJournal,
|
|
||||||
attachable_id: id.into(),
|
|
||||||
};
|
|
||||||
let res = self.client.post(url).json(&data).send().await?;
|
|
||||||
res.error_for_status_ref()?;
|
|
||||||
let attachment: AttachmentSingle = res.json().await?;
|
|
||||||
info!("Created attachment {}", attachment.data.id);
|
|
||||||
let url = format!(
|
|
||||||
"{}/api/v1/attachments/{}/upload",
|
|
||||||
self.url, attachment.data.id
|
|
||||||
);
|
|
||||||
let res = self
|
|
||||||
.client
|
|
||||||
.post(url)
|
|
||||||
.header("Content-Type", "application/octet-stream")
|
|
||||||
.body(content)
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
res.error_for_status_ref()?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn create_transaction(
|
|
||||||
&self,
|
|
||||||
post: TransactionStore,
|
|
||||||
) -> Result<TransactionSingle, reqwest::Error> {
|
|
||||||
let url = format!("{}/api/v1/transactions", self.url);
|
|
||||||
let res = self.client.post(url).json(&post).send().await?;
|
|
||||||
if let Err(e) = res.error_for_status_ref() {
|
|
||||||
error!("Failed to create transaction: {:?}", res.text().await);
|
|
||||||
return Err(e);
|
|
||||||
}
|
|
||||||
res.json().await
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,46 +0,0 @@
|
||||||
use graphicsmagick::types::FilterTypes;
|
|
||||||
use graphicsmagick::wand::MagickWand;
|
|
||||||
|
|
||||||
pub fn thumbnail(
|
|
||||||
image: &[u8],
|
|
||||||
) -> Result<Option<Vec<u8>>, graphicsmagick::Error> {
|
|
||||||
let mut wand = MagickWand::new();
|
|
||||||
wand.read_image_blob(image)?;
|
|
||||||
|
|
||||||
// Multi-page documents like PDFs become multiple images in the
|
|
||||||
// MagickWand. We want a thumbnail of the first page, so we have
|
|
||||||
// to reset the iterator back to the first image.
|
|
||||||
wand.reset_iterator();
|
|
||||||
|
|
||||||
let orig_height = wand.get_image_height() as f64;
|
|
||||||
let orig_width = wand.get_image_width() as f64;
|
|
||||||
let min_width = 300.0;
|
|
||||||
let min_height = 450.0;
|
|
||||||
let scale_w = min_width / orig_width;
|
|
||||||
let scale_h = min_height / orig_height;
|
|
||||||
let scale = scale_w.max(scale_h);
|
|
||||||
let new_width = (orig_width * scale).round() as u64;
|
|
||||||
let new_height = (orig_height * scale).round() as u64;
|
|
||||||
|
|
||||||
wand.resize_image(
|
|
||||||
new_width,
|
|
||||||
new_height,
|
|
||||||
FilterTypes::UndefinedFilter,
|
|
||||||
1.0,
|
|
||||||
)?;
|
|
||||||
Ok(wand
|
|
||||||
.set_image_format("WEBP")?
|
|
||||||
.write_image_blob()
|
|
||||||
.map(|a| a.to_vec()))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_type(image: &[u8]) -> Option<String> {
|
|
||||||
Some(
|
|
||||||
MagickWand::new()
|
|
||||||
.read_image_blob(image)
|
|
||||||
.ok()?
|
|
||||||
.get_image_format()
|
|
||||||
.to_str_lossy()
|
|
||||||
.to_string(),
|
|
||||||
)
|
|
||||||
}
|
|
147
src/main.rs
147
src/main.rs
|
@ -1,118 +1,51 @@
|
||||||
mod config;
|
use axum::extract::Multipart;
|
||||||
mod firefly;
|
use axum::response::Html;
|
||||||
mod imaging;
|
use axum::routing::get;
|
||||||
mod receipts;
|
use axum::Router;
|
||||||
mod routes;
|
use tokio::net::TcpListener;
|
||||||
mod transactions;
|
use tower_http::limit::RequestBodyLimitLayer;
|
||||||
|
use tower_http::trace::{self, TraceLayer};
|
||||||
|
use tracing::error;
|
||||||
|
use tracing::Level;
|
||||||
|
|
||||||
use rocket::fairing::{self, AdHoc};
|
#[tokio::main(flavor = "current_thread")]
|
||||||
use rocket::fs::FileServer;
|
async fn main() {
|
||||||
use rocket::response::{Redirect, Responder};
|
|
||||||
use rocket::serde::json::Json;
|
|
||||||
use rocket::Rocket;
|
|
||||||
use rocket_db_pools::Database as RocketDatabase;
|
|
||||||
use rocket_dyn_templates::Template;
|
|
||||||
use serde::Serialize;
|
|
||||||
use tracing::{debug, error, info};
|
|
||||||
|
|
||||||
use config::Config;
|
|
||||||
use firefly::Firefly;
|
|
||||||
|
|
||||||
struct Context {
|
|
||||||
#[allow(dead_code)]
|
|
||||||
config: Config,
|
|
||||||
firefly: Firefly,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
|
||||||
enum InitError {
|
|
||||||
#[error("Invalid config: {0}")]
|
|
||||||
Config(std::io::Error),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Context {
|
|
||||||
pub fn init(config: Config) -> Result<Self, InitError> {
|
|
||||||
let token = std::fs::read_to_string(&config.firefly.token)
|
|
||||||
.map_err(InitError::Config)?;
|
|
||||||
let firefly = Firefly::new(&config.firefly.url, &token);
|
|
||||||
Ok(Self { config, firefly })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(RocketDatabase)]
|
|
||||||
#[database("receipts")]
|
|
||||||
struct Database(rocket_db_pools::sqlx::PgPool);
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct ErrorBody {
|
|
||||||
error: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Responder)]
|
|
||||||
#[response(status = 500)]
|
|
||||||
struct ErrorResponse {
|
|
||||||
error: Json<ErrorBody>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ErrorResponse {
|
|
||||||
pub fn new<S: Into<String>>(error: S) -> Self {
|
|
||||||
Self {
|
|
||||||
error: Json(ErrorBody {
|
|
||||||
error: error.into(),
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[rocket::get("/")]
|
|
||||||
async fn index() -> Redirect {
|
|
||||||
Redirect::to("/receipts")
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn run_migrations(rocket: Rocket<rocket::Build>) -> fairing::Result {
|
|
||||||
if let Some(db) = Database::fetch(&rocket) {
|
|
||||||
info!("Applying database migrations");
|
|
||||||
if let Err(e) = sqlx::migrate!("./migrations").run(&db.0).await {
|
|
||||||
error!("Database migration failed: {}", e);
|
|
||||||
Err(rocket)
|
|
||||||
} else {
|
|
||||||
Ok(rocket)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Err(rocket)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[rocket::launch]
|
|
||||||
async fn rocket() -> _ {
|
|
||||||
tracing_subscriber::fmt()
|
tracing_subscriber::fmt()
|
||||||
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
||||||
.with_writer(std::io::stderr)
|
.with_writer(std::io::stderr)
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
graphicsmagick::initialize();
|
let app = Router::new()
|
||||||
|
.route("/", get(get_receipts).post(post_receipts))
|
||||||
|
.layer(RequestBodyLimitLayer::new(20971520))
|
||||||
|
.layer(
|
||||||
|
TraceLayer::new_for_http()
|
||||||
|
.make_span_with(
|
||||||
|
trace::DefaultMakeSpan::new().level(Level::INFO),
|
||||||
|
)
|
||||||
|
.on_response(
|
||||||
|
trace::DefaultOnResponse::new().level(Level::INFO),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
let rocket = rocket::build();
|
let listen_address = match std::env::var("LISTEN_ADDRESS") {
|
||||||
let figment = rocket.figment();
|
Ok(v) => v,
|
||||||
|
Err(std::env::VarError::NotPresent) => "0.0.0.0:3000".into(),
|
||||||
let config: Config = figment.extract().unwrap();
|
Err(std::env::VarError::NotUnicode(_)) => {
|
||||||
|
error!("Invalid UTF-8 value for LISTEN_ADDRESS");
|
||||||
let ctx = match Context::init(config) {
|
|
||||||
Ok(c) => c,
|
|
||||||
Err(e) => {
|
|
||||||
error!("Failed to initialize application context: {}", e);
|
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
},
|
}
|
||||||
};
|
};
|
||||||
debug!("Using Firefly III URL {}", &ctx.firefly.url());
|
|
||||||
|
|
||||||
rocket
|
let listener = TcpListener::bind(&listen_address).await.unwrap();
|
||||||
.manage(ctx)
|
axum::serve(listener, app).await.unwrap();
|
||||||
.mount("/", rocket::routes![index])
|
}
|
||||||
.mount("/transactions", routes::transactions::routes())
|
|
||||||
.mount("/receipts", routes::receipts::routes())
|
async fn get_receipts() -> Html<&'static str> {
|
||||||
.mount("/static", FileServer::from("static"))
|
Html(include_str!("../index.html"))
|
||||||
.attach(Template::fairing())
|
}
|
||||||
.attach(Database::init())
|
|
||||||
.attach(AdHoc::try_on_ignite("Migrate Database", run_migrations))
|
async fn post_receipts(mut multipart: Multipart) -> Html<&'static str> {
|
||||||
|
while let Ok(Some(_)) = multipart.next_field().await {}
|
||||||
|
Html("Ok")
|
||||||
}
|
}
|
||||||
|
|
205
src/receipts.rs
205
src/receipts.rs
|
@ -1,205 +0,0 @@
|
||||||
use chrono::{DateTime, FixedOffset};
|
|
||||||
use rocket::fs::TempFile;
|
|
||||||
use rocket::tokio::io::{AsyncReadExt, BufReader};
|
|
||||||
use rocket_db_pools::Connection;
|
|
||||||
use serde::Serialize;
|
|
||||||
use sqlx::types::Decimal;
|
|
||||||
use tracing::error;
|
|
||||||
|
|
||||||
use crate::imaging;
|
|
||||||
use crate::Database;
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
pub struct Receipt {
|
|
||||||
pub id: i32,
|
|
||||||
pub date: DateTime<FixedOffset>,
|
|
||||||
pub vendor: String,
|
|
||||||
pub amount: Decimal,
|
|
||||||
pub notes: Option<String>,
|
|
||||||
pub filename: String,
|
|
||||||
pub image: Vec<u8>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
pub struct ReceiptJson {
|
|
||||||
pub id: i32,
|
|
||||||
pub date: DateTime<FixedOffset>,
|
|
||||||
pub vendor: String,
|
|
||||||
pub amount: Decimal,
|
|
||||||
pub notes: Option<String>,
|
|
||||||
pub filename: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(rocket::FromForm)]
|
|
||||||
pub struct ReceiptPostForm<'r> {
|
|
||||||
pub transaction: Option<String>,
|
|
||||||
pub date: String,
|
|
||||||
pub vendor: String,
|
|
||||||
pub amount: String,
|
|
||||||
pub is_restaurant: Option<bool>,
|
|
||||||
pub notes: String,
|
|
||||||
pub photo: TempFile<'r>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct ReceiptPostData {
|
|
||||||
pub date: DateTime<FixedOffset>,
|
|
||||||
pub vendor: String,
|
|
||||||
pub amount: Decimal,
|
|
||||||
pub is_restaurant: bool,
|
|
||||||
pub notes: String,
|
|
||||||
pub filename: String,
|
|
||||||
pub photo: Vec<u8>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
|
||||||
pub enum ReceiptPostFormError {
|
|
||||||
#[error("Invalid date: {0}")]
|
|
||||||
Date(#[from] chrono::format::ParseError),
|
|
||||||
#[error("Invalid amount: {0}")]
|
|
||||||
Amount(#[from] rust_decimal::Error),
|
|
||||||
#[error("Error reading photo: {0}")]
|
|
||||||
Photo(#[from] std::io::Error),
|
|
||||||
#[error("Unsupported image type")]
|
|
||||||
UnsupportedImageFormat,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
#[serde(rename_all = "lowercase")]
|
|
||||||
pub enum AddReceiptResponse {
|
|
||||||
Success(ReceiptJson),
|
|
||||||
Error(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
#[serde(rename_all = "lowercase")]
|
|
||||||
pub enum DeleteReceiptResponse {
|
|
||||||
Success,
|
|
||||||
Error(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ReceiptPostData {
|
|
||||||
pub async fn from_form(
|
|
||||||
form: &ReceiptPostForm<'_>,
|
|
||||||
) -> Result<Self, ReceiptPostFormError> {
|
|
||||||
let date = DateTime::parse_from_str(
|
|
||||||
&format!("{} 00:00:00 +0000", form.date),
|
|
||||||
"%Y-%m-%d %H:%M:%S %z",
|
|
||||||
)?;
|
|
||||||
let vendor = form.vendor.clone();
|
|
||||||
use rust_decimal::prelude::FromStr;
|
|
||||||
let amount = Decimal::from_str(&form.amount)?;
|
|
||||||
let is_restaurant = form.is_restaurant.unwrap_or_default();
|
|
||||||
let notes = form.notes.clone();
|
|
||||||
let stream = form.photo.open().await?;
|
|
||||||
let mut reader = BufReader::new(stream);
|
|
||||||
let mut photo = Vec::new();
|
|
||||||
reader.read_to_end(&mut photo).await?;
|
|
||||||
let extension = match imaging::get_type(&photo).as_deref() {
|
|
||||||
Some("BMP") => "bmp",
|
|
||||||
Some("JPEG") => "jpg",
|
|
||||||
Some("PDF") => "pdf",
|
|
||||||
Some("PNG") => "png",
|
|
||||||
Some("PPM") => "ppm",
|
|
||||||
Some("TIFF") => "tiff",
|
|
||||||
Some("WEBP") => "webp",
|
|
||||||
Some(f) => {
|
|
||||||
error!("Unsupported image format: {}", f);
|
|
||||||
return Err(ReceiptPostFormError::UnsupportedImageFormat);
|
|
||||||
},
|
|
||||||
None => {
|
|
||||||
return Err(ReceiptPostFormError::UnsupportedImageFormat);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
let filename = form
|
|
||||||
.photo
|
|
||||||
.name()
|
|
||||||
.map(|n| format!("{}.{}", n, extension))
|
|
||||||
.unwrap_or("photo.jpg".into());
|
|
||||||
Ok(Self {
|
|
||||||
date,
|
|
||||||
vendor,
|
|
||||||
amount,
|
|
||||||
is_restaurant,
|
|
||||||
notes,
|
|
||||||
filename,
|
|
||||||
photo,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct ReceiptsRepository {
|
|
||||||
conn: Connection<Database>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ReceiptsRepository {
|
|
||||||
pub fn new(conn: Connection<Database>) -> Self {
|
|
||||||
Self { conn }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_receipts(
|
|
||||||
&mut self,
|
|
||||||
limit: i64,
|
|
||||||
offset: i64,
|
|
||||||
) -> Result<Vec<ReceiptJson>, sqlx::Error> {
|
|
||||||
sqlx::query_file_as!(ReceiptJson, "sql/receipts/list-receipts.sql", limit, offset)
|
|
||||||
.fetch_all(&mut **self.conn)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn count_receipts(&mut self) -> Result<i64, sqlx::Error> {
|
|
||||||
Ok(sqlx::query_file!("sql/receipts/count-receipts.sql")
|
|
||||||
.fetch_one(&mut **self.conn)
|
|
||||||
.await?
|
|
||||||
.count)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn add_receipt(
|
|
||||||
&mut self,
|
|
||||||
data: &ReceiptPostData,
|
|
||||||
) -> Result<ReceiptJson, sqlx::Error> {
|
|
||||||
let result = sqlx::query_file_as!(
|
|
||||||
ReceiptJson,
|
|
||||||
"sql/receipts/insert-receipt.sql",
|
|
||||||
data.vendor,
|
|
||||||
data.date,
|
|
||||||
data.amount,
|
|
||||||
data.notes,
|
|
||||||
data.filename,
|
|
||||||
data.photo,
|
|
||||||
)
|
|
||||||
.fetch_one(&mut **self.conn)
|
|
||||||
.await?;
|
|
||||||
Ok(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_receipt(
|
|
||||||
&mut self,
|
|
||||||
id: i32,
|
|
||||||
) -> Result<ReceiptJson, sqlx::Error> {
|
|
||||||
sqlx::query_file_as!(ReceiptJson, "sql/receipts/get-receipt.sql", id,)
|
|
||||||
.fetch_one(&mut **self.conn)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
pub async fn get_receipt_photo(
|
|
||||||
&mut self,
|
|
||||||
id: i32,
|
|
||||||
) -> Result<(String, Vec<u8>), sqlx::Error> {
|
|
||||||
let result = sqlx::query!(
|
|
||||||
"SELECT filename, image FROM receipts WHERE id = $1",
|
|
||||||
id,
|
|
||||||
)
|
|
||||||
.fetch_one(&mut **self.conn)
|
|
||||||
.await?;
|
|
||||||
Ok((result.filename, result.image))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn delete_receipt(
|
|
||||||
&mut self,
|
|
||||||
id: i32,
|
|
||||||
) -> Result<(), sqlx::Error> {
|
|
||||||
sqlx::query_as!(ReceiptJson, "DELETE FROM receipts WHERE id = $1", id)
|
|
||||||
.execute(&mut **self.conn)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,2 +0,0 @@
|
||||||
pub mod receipts;
|
|
||||||
pub mod transactions;
|
|
|
@ -1,382 +0,0 @@
|
||||||
use std::ops::RangeInclusive;
|
|
||||||
|
|
||||||
use rocket::form::Form;
|
|
||||||
use rocket::http::{ContentType, Header, MediaType, Status};
|
|
||||||
use rocket::serde::json::Json;
|
|
||||||
use rocket::{Route, State};
|
|
||||||
use rocket_db_pools::Connection as DatabaseConnection;
|
|
||||||
use rocket_dyn_templates::{context, Template};
|
|
||||||
use rust_decimal::prelude::ToPrimitive;
|
|
||||||
use tracing::{debug, error, info, trace};
|
|
||||||
|
|
||||||
use crate::firefly::{TransactionStore, TransactionUpdate};
|
|
||||||
use crate::imaging;
|
|
||||||
use crate::receipts::*;
|
|
||||||
use crate::{Context, Database};
|
|
||||||
|
|
||||||
fn paginate(total: i64, count: i64, current: i64) -> Vec<String> {
|
|
||||||
let start = 1;
|
|
||||||
let end = (total / count).max(1);
|
|
||||||
let pages = RangeInclusive::new(start, end);
|
|
||||||
if end < 10 {
|
|
||||||
pages.map(|p| format!("{}", p)).collect()
|
|
||||||
} else {
|
|
||||||
pages
|
|
||||||
.filter_map(|p| {
|
|
||||||
if p == start
|
|
||||||
|| (current - 2 <= p && p <= current + 2)
|
|
||||||
|| p == end
|
|
||||||
{
|
|
||||||
Some(format!("{}", p))
|
|
||||||
} else if p == current - 3 || p == current + 3 {
|
|
||||||
Some("...".into())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[rocket::get("/?<page>&<count>")]
|
|
||||||
pub async fn list_receipts(
|
|
||||||
db: DatabaseConnection<Database>,
|
|
||||||
page: Option<i64>,
|
|
||||||
count: Option<i64>,
|
|
||||||
) -> (Status, Template) {
|
|
||||||
let mut repo = ReceiptsRepository::new(db);
|
|
||||||
let count = count.unwrap_or(25);
|
|
||||||
let page = page.unwrap_or(1);
|
|
||||||
let total = match repo.count_receipts().await {
|
|
||||||
Ok(r) => r,
|
|
||||||
Err(e) => {
|
|
||||||
return (
|
|
||||||
Status::InternalServerError,
|
|
||||||
Template::render(
|
|
||||||
"error",
|
|
||||||
context! {
|
|
||||||
error: e.to_string(),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
};
|
|
||||||
match repo.list_receipts(count, (page - 1) * count).await {
|
|
||||||
Ok(r) => (
|
|
||||||
Status::Ok,
|
|
||||||
Template::render(
|
|
||||||
"receipt-list",
|
|
||||||
context! {
|
|
||||||
receipts: r,
|
|
||||||
pages: paginate(total, count, page),
|
|
||||||
count: count,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Err(e) => (
|
|
||||||
Status::InternalServerError,
|
|
||||||
Template::render(
|
|
||||||
"error",
|
|
||||||
context! {
|
|
||||||
error: e.to_string(),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[rocket::get("/add")]
|
|
||||||
pub async fn receipt_form() -> Template {
|
|
||||||
Template::render("receipt-form", context! {})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[rocket::post("/add", data = "<form>")]
|
|
||||||
pub async fn add_receipt(
|
|
||||||
form: Form<ReceiptPostForm<'_>>,
|
|
||||||
db: DatabaseConnection<Database>,
|
|
||||||
ctx: &State<Context>,
|
|
||||||
) -> (Status, Json<AddReceiptResponse>) {
|
|
||||||
let data = match ReceiptPostData::from_form(&form).await {
|
|
||||||
Ok(d) => d,
|
|
||||||
Err(e) => {
|
|
||||||
return (
|
|
||||||
Status::BadRequest,
|
|
||||||
Json(AddReceiptResponse::Error(e.to_string())),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
let mut repo = ReceiptsRepository::new(db);
|
|
||||||
let receipt = match repo.add_receipt(&data).await {
|
|
||||||
Ok(r) => {
|
|
||||||
info!("Created new receipt {}", r.id);
|
|
||||||
r
|
|
||||||
},
|
|
||||||
Err(e) => {
|
|
||||||
error!("Failed to insert new receipt record: {}", e);
|
|
||||||
return (
|
|
||||||
Status::InternalServerError,
|
|
||||||
Json(AddReceiptResponse::Error(e.to_string())),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
let xact = match form.transaction {
|
|
||||||
Some(ref s) if s == "new" => {
|
|
||||||
let tags = if data.is_restaurant {
|
|
||||||
Some(vec![ctx.config.firefly.restaurant_tag.clone()])
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
let data = TransactionStore::new_withdrawal(
|
|
||||||
data.date,
|
|
||||||
data.amount.to_string(),
|
|
||||||
data.vendor,
|
|
||||||
ctx.config.firefly.default_account.clone(),
|
|
||||||
Some("(no name)".into()),
|
|
||||||
data.notes,
|
|
||||||
tags,
|
|
||||||
);
|
|
||||||
match ctx.firefly.create_transaction(data).await {
|
|
||||||
Ok(t) => {
|
|
||||||
info!("Successfully created transaction ID {}", t.data.id);
|
|
||||||
Some(t)
|
|
||||||
},
|
|
||||||
Err(e) => {
|
|
||||||
error!("Failed to create Firefly transaction: {}", e);
|
|
||||||
None
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Some(ref s) if s == "deposit" => {
|
|
||||||
let data = TransactionStore::new_deposit(
|
|
||||||
data.date,
|
|
||||||
data.amount.to_string(),
|
|
||||||
data.vendor,
|
|
||||||
Some("(no name)".into()),
|
|
||||||
ctx.config.firefly.default_account.clone(),
|
|
||||||
data.notes,
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
match ctx.firefly.create_transaction(data).await {
|
|
||||||
Ok(t) => {
|
|
||||||
info!("Successfully created transaction ID {}", t.data.id);
|
|
||||||
Some(t)
|
|
||||||
},
|
|
||||||
Err(e) => {
|
|
||||||
error!("Failed to create Firefly transaction: {}", e);
|
|
||||||
None
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Some(ref id) if !id.is_empty() => match ctx
|
|
||||||
.firefly
|
|
||||||
.get_transaction(id)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(t) => {
|
|
||||||
let mut needs_update = false;
|
|
||||||
let mut update = TransactionUpdate::from(t.data.clone());
|
|
||||||
let amount = t.data.amount();
|
|
||||||
if let Some(split) = update.transactions.last_mut() {
|
|
||||||
if data.amount.to_f64() != Some(amount) {
|
|
||||||
split.amount = data.amount.to_string();
|
|
||||||
needs_update = true;
|
|
||||||
}
|
|
||||||
if data.vendor != split.description {
|
|
||||||
split.description = data.vendor;
|
|
||||||
needs_update = true;
|
|
||||||
}
|
|
||||||
if !data.notes.is_empty() {
|
|
||||||
if let Some(notes) = split.notes.as_deref() {
|
|
||||||
if notes != data.notes.as_str() {
|
|
||||||
split.notes = Some(data.notes.clone());
|
|
||||||
needs_update = true;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
split.notes = data.notes.into();
|
|
||||||
needs_update = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if data.is_restaurant {
|
|
||||||
if let Some(tags) = &mut split.tags {
|
|
||||||
if !tags
|
|
||||||
.contains(&ctx.config.firefly.restaurant_tag)
|
|
||||||
{
|
|
||||||
tags.push(
|
|
||||||
ctx.config.firefly.restaurant_tag.clone(),
|
|
||||||
);
|
|
||||||
needs_update = true;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
split.tags.replace(vec![ctx
|
|
||||||
.config
|
|
||||||
.firefly
|
|
||||||
.restaurant_tag
|
|
||||||
.clone()]);
|
|
||||||
needs_update = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
trace!("Original transaction: {:?}", split);
|
|
||||||
trace!("Updated transaction: {:?}", update);
|
|
||||||
} else {
|
|
||||||
debug!("Transaction {} has no splits", id);
|
|
||||||
}
|
|
||||||
if needs_update {
|
|
||||||
let res = ctx
|
|
||||||
.firefly
|
|
||||||
.update_transaction(&t.data.id, &update)
|
|
||||||
.await;
|
|
||||||
match res {
|
|
||||||
Ok(t) => {
|
|
||||||
info!("Successfully updated transaction {}", id);
|
|
||||||
Some(t)
|
|
||||||
},
|
|
||||||
Err(e) => {
|
|
||||||
error!(
|
|
||||||
"Failed to update trancation {}: {}",
|
|
||||||
id, e
|
|
||||||
);
|
|
||||||
Some(t)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
debug!("Transaction {} does not need updated", id);
|
|
||||||
Some(t)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Err(e) => {
|
|
||||||
error!("Could not load Firefly transaction {}: {}", id, e);
|
|
||||||
None
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Some(_) => None,
|
|
||||||
None => None,
|
|
||||||
};
|
|
||||||
if let Some(xact) = xact {
|
|
||||||
if let Some(t) = xact.data.attributes.transactions.first() {
|
|
||||||
if let Err(e) = ctx
|
|
||||||
.firefly
|
|
||||||
.attach_receipt(
|
|
||||||
&t.transaction_journal_id,
|
|
||||||
data.photo,
|
|
||||||
data.filename,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
error!(
|
|
||||||
"Failed to attach receipt to Firefly transaction: {}",
|
|
||||||
e
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(Status::Ok, Json(AddReceiptResponse::Success(receipt)))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[rocket::get("/<id>")]
|
|
||||||
pub async fn get_receipt(
|
|
||||||
id: i32,
|
|
||||||
db: DatabaseConnection<Database>,
|
|
||||||
) -> Option<Template> {
|
|
||||||
let mut repo = ReceiptsRepository::new(db);
|
|
||||||
match repo.get_receipt(id).await {
|
|
||||||
Ok(r) => Some(Template::render("receipt", r)),
|
|
||||||
Err(e) => {
|
|
||||||
error!("Error fetching receipt image: {}", e);
|
|
||||||
None
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(rocket::response::Responder)]
|
|
||||||
pub struct PhotoResponse {
|
|
||||||
content: Vec<u8>,
|
|
||||||
content_type: ContentType,
|
|
||||||
cache_control: Header<'static>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PhotoResponse {
|
|
||||||
fn new(content: Vec<u8>, content_type: ContentType) -> Self {
|
|
||||||
let cache_control = Header::new("Cache-Control", "max-age=604800");
|
|
||||||
Self {
|
|
||||||
content,
|
|
||||||
content_type,
|
|
||||||
cache_control,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[rocket::get("/<id>/thumbnail/<_>")]
|
|
||||||
pub async fn view_receipt_thumbnail(
|
|
||||||
id: i32,
|
|
||||||
db: DatabaseConnection<Database>,
|
|
||||||
) -> Option<PhotoResponse> {
|
|
||||||
let mut repo = ReceiptsRepository::new(db);
|
|
||||||
match repo.get_receipt_photo(id).await {
|
|
||||||
Ok((_, image)) => {
|
|
||||||
let thumbnail = match imaging::thumbnail(&image) {
|
|
||||||
Ok(Some(t)) => t,
|
|
||||||
Ok(None) => return None,
|
|
||||||
Err(e) => {
|
|
||||||
error!("Failed to create receipt photo thumbnail: {}", e);
|
|
||||||
return None;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
Some(PhotoResponse::new(thumbnail, ContentType(MediaType::WEBP)))
|
|
||||||
},
|
|
||||||
Err(e) => {
|
|
||||||
error!("Error fetching receipt image: {}", e);
|
|
||||||
None
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[rocket::get("/<id>/view/<_>")]
|
|
||||||
pub async fn view_receipt_photo(
|
|
||||||
id: i32,
|
|
||||||
db: DatabaseConnection<Database>,
|
|
||||||
) -> Option<PhotoResponse> {
|
|
||||||
let mut repo = ReceiptsRepository::new(db);
|
|
||||||
match repo.get_receipt_photo(id).await {
|
|
||||||
Ok((filename, image)) => {
|
|
||||||
let mt = filename
|
|
||||||
.rsplit_once('.')
|
|
||||||
.and_then(|(_, ext)| MediaType::from_extension(ext))
|
|
||||||
.unwrap_or(MediaType::Binary);
|
|
||||||
Some(PhotoResponse::new(image, ContentType(mt)))
|
|
||||||
},
|
|
||||||
Err(e) => {
|
|
||||||
error!("Error fetching receipt image: {}", e);
|
|
||||||
None
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[rocket::delete("/<id>")]
|
|
||||||
pub async fn delete_receipt(
|
|
||||||
id: i32,
|
|
||||||
db: DatabaseConnection<Database>,
|
|
||||||
) -> (Status, Json<DeleteReceiptResponse>) {
|
|
||||||
let mut repo = ReceiptsRepository::new(db);
|
|
||||||
match repo.delete_receipt(id).await {
|
|
||||||
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,
|
|
||||||
view_receipt_thumbnail,
|
|
||||||
view_receipt_photo,
|
|
||||||
]
|
|
||||||
}
|
|
|
@ -1,45 +0,0 @@
|
||||||
use rocket::serde::json::Json;
|
|
||||||
use rocket::Route;
|
|
||||||
use rocket::State;
|
|
||||||
use tracing::error;
|
|
||||||
|
|
||||||
use crate::transactions::*;
|
|
||||||
use crate::{Context, ErrorResponse};
|
|
||||||
|
|
||||||
#[rocket::get("/", format = "json")]
|
|
||||||
async fn transaction_list(
|
|
||||||
ctx: &State<Context>,
|
|
||||||
) -> Result<Json<Vec<Transaction>>, ErrorResponse> {
|
|
||||||
let result = ctx
|
|
||||||
.firefly
|
|
||||||
.search_transactions(&ctx.config.firefly.search_query)
|
|
||||||
.await
|
|
||||||
.map_err(|e| {
|
|
||||||
error!("Error fetching transaction list: {}", e);
|
|
||||||
ErrorResponse::new(
|
|
||||||
"Failed to fetch transaction list from Firefly III",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let restaurant_tag = Some(&ctx.config.firefly.restaurant_tag);
|
|
||||||
|
|
||||||
Ok(Json(
|
|
||||||
result
|
|
||||||
.data
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|t| {
|
|
||||||
match Transaction::from_firefly(t, restaurant_tag) {
|
|
||||||
Ok(t) => Some(t),
|
|
||||||
Err(e) => {
|
|
||||||
error!("Error parsing transaction details: {}", e);
|
|
||||||
None
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn routes() -> Vec<Route> {
|
|
||||||
rocket::routes![transaction_list]
|
|
||||||
}
|
|
|
@ -1,52 +0,0 @@
|
||||||
use chrono::{DateTime, FixedOffset};
|
|
||||||
use serde::Serialize;
|
|
||||||
use tracing::error;
|
|
||||||
|
|
||||||
use crate::firefly::TransactionRead;
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
pub struct Transaction {
|
|
||||||
pub id: String,
|
|
||||||
pub amount: f64,
|
|
||||||
pub description: String,
|
|
||||||
pub date: DateTime<FixedOffset>,
|
|
||||||
pub is_restaurant: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Transaction {
|
|
||||||
pub fn from_firefly<T: AsRef<str>>(
|
|
||||||
t: TransactionRead,
|
|
||||||
restaurant_tag: Option<T>,
|
|
||||||
) -> Result<Self, &'static str> {
|
|
||||||
let first_split = match t.attributes.transactions.first() {
|
|
||||||
Some(t) => t,
|
|
||||||
None => {
|
|
||||||
error!("Invalid transaction {}: no splits", t.id);
|
|
||||||
return Err("Transaction has no splits");
|
|
||||||
},
|
|
||||||
};
|
|
||||||
let date = first_split.date;
|
|
||||||
let amount = t.amount();
|
|
||||||
let description = if let Some(title) = &t.attributes.group_title {
|
|
||||||
title.into()
|
|
||||||
} else {
|
|
||||||
first_split.description.clone()
|
|
||||||
};
|
|
||||||
let is_restaurant = if let Some(tag) = restaurant_tag {
|
|
||||||
if let Some(tags) = &first_split.tags {
|
|
||||||
tags.iter().any(|a| a == tag.as_ref())
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
};
|
|
||||||
Ok(Self {
|
|
||||||
id: t.id,
|
|
||||||
amount,
|
|
||||||
description,
|
|
||||||
date,
|
|
||||||
is_restaurant,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,9 +0,0 @@
|
||||||
#!/bin/sh
|
|
||||||
podman run \
|
|
||||||
--rm \
|
|
||||||
-d \
|
|
||||||
--name firefly-iii \
|
|
||||||
--env-file firefly.env \
|
|
||||||
-v firefly-iii:/var/www/html/storage \
|
|
||||||
-p 8080:8080 \
|
|
||||||
docker.io/fireflyiii/core
|
|
|
@ -1,10 +0,0 @@
|
||||||
#!/bin/sh
|
|
||||||
mkdir -p .postgresql
|
|
||||||
podman run \
|
|
||||||
--rm \
|
|
||||||
-d \
|
|
||||||
--name postgresql \
|
|
||||||
-e POSTGRES_PASSWORD=$(tr -cd a-zA-Z0-9 < /dev/urandom | head -c 32) \
|
|
||||||
-v postgresql:/var/lib/postgresql/data \
|
|
||||||
-v ./.postgresql:/var/run/postgresql:rw,z \
|
|
||||||
docker.io/library/postgres
|
|
|
@ -1,24 +0,0 @@
|
||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<link
|
|
||||||
rel="icon"
|
|
||||||
href="/static/icons/icon-192.png"
|
|
||||||
sizes="192x192"
|
|
||||||
type="image/png"
|
|
||||||
/>
|
|
||||||
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png" />
|
|
||||||
<link rel="stylesheet" href="/static/common.css" />
|
|
||||||
<script src="/static/common.js"></script>
|
|
||||||
{% block head %}{% endblock -%}
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="page-loading">
|
|
||||||
<div>Loading ...</div>
|
|
||||||
</div>
|
|
||||||
<main class="container">{% block main %}{% endblock %}</main>
|
|
||||||
{% block scripts %}{% endblock %}
|
|
||||||
</body>
|
|
||||||
</html>
|
|
|
@ -1,6 +0,0 @@
|
||||||
{% extends "base" %} {% block head %}
|
|
||||||
<title>Error</title>
|
|
||||||
{% endblock %} {% block main %}
|
|
||||||
<h1>Error</h1>
|
|
||||||
<p style="color: var(--sl-color-red-600)">{{ error }}</p>
|
|
||||||
{% endblock %}
|
|
|
@ -1,79 +0,0 @@
|
||||||
{% extends "base" %} {% block head %}
|
|
||||||
<title>Add Receipt</title>
|
|
||||||
<style></style>
|
|
||||||
{% endblock %} {% block main %}
|
|
||||||
<h1>Add Receipt</h1>
|
|
||||||
<nav>
|
|
||||||
<sl-button href="/receipts">
|
|
||||||
<sl-icon slot="prefix" name="chevron-left"></sl-icon>Receipts
|
|
||||||
</sl-button>
|
|
||||||
</nav>
|
|
||||||
<article>
|
|
||||||
<form>
|
|
||||||
<p>
|
|
||||||
<sl-select
|
|
||||||
id="transactions"
|
|
||||||
name="transaction"
|
|
||||||
placeholder="Loading transactions …"
|
|
||||||
help-text="Select an existing transaction to auto fill fields"
|
|
||||||
clearable
|
|
||||||
>
|
|
||||||
<sl-spinner slot="suffix"></sl-spinner>
|
|
||||||
<sl-option value="new">New Gas Station Transaction …</sl-option>
|
|
||||||
<sl-option value="deposit">New Refund/Deposit Transaction …</sl-option>
|
|
||||||
</sl-select>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<sl-input
|
|
||||||
name="vendor"
|
|
||||||
label="Vendor"
|
|
||||||
placeholder="Vendor"
|
|
||||||
list="vendors"
|
|
||||||
required
|
|
||||||
></sl-input>
|
|
||||||
</p>
|
|
||||||
<p><sl-switch name="is_restaurant">Restaurant</sl-switch>
|
|
||||||
<p>
|
|
||||||
<sl-input type="date" name="date" label="Date" required></sl-input>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<sl-input
|
|
||||||
type="number"
|
|
||||||
min="0.01"
|
|
||||||
step="0.01"
|
|
||||||
label="Amount"
|
|
||||||
name="amount"
|
|
||||||
required
|
|
||||||
></sl-input>
|
|
||||||
</p>
|
|
||||||
<p><sl-textarea label="Notes" name="notes"></sl-textarea></p>
|
|
||||||
<sl-details summary="Take Photo" id="photo-box">
|
|
||||||
<camera-input>Your browser does not support taking photos.</camera-input>
|
|
||||||
</sl-details>
|
|
||||||
<sl-details summary="Upload Photo" style="text-align: center">
|
|
||||||
<p>
|
|
||||||
<img id="upload-preview" style="max-height: 400px" />
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<input
|
|
||||||
name="photo"
|
|
||||||
type="file"
|
|
||||||
style="display: none"
|
|
||||||
accept="image/*,application/pdf,.avif"
|
|
||||||
/>
|
|
||||||
<sl-button size="large" variant="primary" class="choose-file">
|
|
||||||
<sl-icon slot="prefix" name="image" label="Choose File"></sl-icon>
|
|
||||||
Choose File
|
|
||||||
</sl-button>
|
|
||||||
</p>
|
|
||||||
</sl-details>
|
|
||||||
<footer class="table-actions">
|
|
||||||
<sl-button type="reset">Reset</sl-button>
|
|
||||||
<sl-button type="submit" variant="primary" disabled>Submit</sl-button>
|
|
||||||
</footer>
|
|
||||||
</form>
|
|
||||||
</article>
|
|
||||||
{% endblock %}
|
|
||||||
{% block scripts %}
|
|
||||||
<script src="/static/receipt-form.js"></script>
|
|
||||||
{% endblock %}
|
|
|
@ -1,155 +0,0 @@
|
||||||
{% extends "base" %} {% block head %}
|
|
||||||
<title>Receipts</title>
|
|
||||||
<style>
|
|
||||||
article {
|
|
||||||
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 {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.receipt-card .vendor {
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: 115%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.receipt-card .date {
|
|
||||||
font-size: 75%;
|
|
||||||
color: var(--sl-color-neutral-500);
|
|
||||||
vertical-align: bottom;
|
|
||||||
}
|
|
||||||
|
|
||||||
.receipt-card :link,
|
|
||||||
.receipt-card :visited {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul.pagination {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
list-style-type: none;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul.pagination li {
|
|
||||||
display: inline-block;
|
|
||||||
margin: 1em;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endblock %} {% block main %}
|
|
||||||
<h1>Receipts</h1>
|
|
||||||
<p class="table-actions">
|
|
||||||
<sl-button variant="primary" size="large" href="/receipts/add"
|
|
||||||
><sl-icon slot="prefix" name="file-earmark-plus"></sl-icon>Add
|
|
||||||
Receipt</sl-button
|
|
||||||
>
|
|
||||||
</p>
|
|
||||||
<article>
|
|
||||||
{% for receipt in receipts -%}
|
|
||||||
<sl-card class="receipt-card" data-receipt-id="{{ receipt.id }}">
|
|
||||||
<a slot="image" href="/receipts/{{ receipt.id }}">
|
|
||||||
<img
|
|
||||||
src="/receipts/{{ receipt.id }}/thumbnail/{{ 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>
|
|
||||||
<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>
|
|
||||||
<ul class="pagination">
|
|
||||||
{%- for page in pages %}
|
|
||||||
<li>{% if page == "..." %}...{% else
|
|
||||||
%}<sl-button href="?page={{ page }}&count={{ count }}">{{ page }}</sl-button>{%
|
|
||||||
endif %}</li>
|
|
||||||
{%- endfor %}
|
|
||||||
</ul>
|
|
||||||
<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>
|
|
||||||
{% endblock %}
|
|
|
@ -1,83 +0,0 @@
|
||||||
{% extends "base" %} {% block head %}
|
|
||||||
<title>Receipt: ${{ amount }} at {{ vendor }}</title>
|
|
||||||
<style>
|
|
||||||
.photo {
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.photo img {
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.photo iframe {
|
|
||||||
width: 100%;
|
|
||||||
aspect-ratio: 8.5 / 11;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (min-width: 900px) {
|
|
||||||
article {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.attributes {
|
|
||||||
margin-right: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.photo {
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endblock %} {% block main %}
|
|
||||||
<h1>Receipt</h1>
|
|
||||||
<nav>
|
|
||||||
<sl-button href="/receipts">
|
|
||||||
<sl-icon slot="prefix" name="chevron-left"></sl-icon>Receipts
|
|
||||||
</sl-button>
|
|
||||||
</nav>
|
|
||||||
<article>
|
|
||||||
<div class="attributes">
|
|
||||||
<p>
|
|
||||||
<sl-input
|
|
||||||
label="Date"
|
|
||||||
value='{{ date | date(format="%A %_d %B %Y") }}'
|
|
||||||
readonly
|
|
||||||
></sl-input>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<sl-input label="Vendor" value="{{ vendor }}" readonly></sl-input>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<sl-input
|
|
||||||
type="number"
|
|
||||||
min="0.01"
|
|
||||||
step="0.01"
|
|
||||||
label="Amount"
|
|
||||||
name="amount"
|
|
||||||
value="{{ amount }}"
|
|
||||||
readonly
|
|
||||||
></sl-input>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<sl-textarea
|
|
||||||
label="Notes"
|
|
||||||
name="notes"
|
|
||||||
value="{{ notes }}"
|
|
||||||
readonly
|
|
||||||
></sl-textarea>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="photo">
|
|
||||||
<p>
|
|
||||||
{% if filename is ending_with(".pdf") %}
|
|
||||||
<iframe src="/receipts/{{ id }}/view/{{ filename }}"></iframe>
|
|
||||||
{% else %}
|
|
||||||
<img src="/receipts/{{ id }}/view/{{ filename }}" />
|
|
||||||
{% endif %}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
{% endblock %} {% block scripts %}
|
|
||||||
<script src="/static/receipt.js"></script>
|
|
||||||
{% endblock %}
|
|
|
@ -1,28 +0,0 @@
|
||||||
{% extends "base" %}
|
|
||||||
{% block head %}
|
|
||||||
<title>Transactions</title>
|
|
||||||
{% endblock %}
|
|
||||||
{% block main %}
|
|
||||||
<h1>Transactions</h1>
|
|
||||||
<p>
|
|
||||||
These transactions have not been reviewed and do not have attached receipts.
|
|
||||||
</p>
|
|
||||||
<table>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<th scope="col">Description</th>
|
|
||||||
<th scope="col">Date</th>
|
|
||||||
<th scope="col">Amount</th>
|
|
||||||
</tr>
|
|
||||||
{% for txn in transactions -%}
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<a href="/transactions/{{ txn.id }}">{{ txn.description }}</a>
|
|
||||||
</td>
|
|
||||||
<td>{{ txn.date }}</td>
|
|
||||||
<td>${{ txn.amount }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor -%}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{% endblock %}
|
|
|
@ -1,59 +0,0 @@
|
||||||
{% extends "base" %}
|
|
||||||
{% block head %}
|
|
||||||
<link rel="stylesheet" href="/static/transaction.css" />
|
|
||||||
<title>Update Transaction: {{ description }}</title>
|
|
||||||
{% endblock %}
|
|
||||||
{% block main %}
|
|
||||||
<h1>Update Transaction</h1>
|
|
||||||
<nav>
|
|
||||||
<sl-breadcrumb>
|
|
||||||
<sl-breadcrumb-item href="/transactions">Transactions</sl-breadcrumb-item>
|
|
||||||
<sl-breadcrumb-item>{{ description }}</sl-breadcrumb-item>
|
|
||||||
</sl-breadcrumb>
|
|
||||||
</nav>
|
|
||||||
<form name="transaction">
|
|
||||||
<p>
|
|
||||||
<sl-input label="Date" value="{{ date }}" readonly></sl-input>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<sl-input label="Description" value="{{ description }}" readonly></sl-input>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<sl-input
|
|
||||||
type="number"
|
|
||||||
min="0.01"
|
|
||||||
step="0.01"
|
|
||||||
label="Amount"
|
|
||||||
name="amount"
|
|
||||||
value="{{ amount }}"
|
|
||||||
></sl-input>
|
|
||||||
</p>
|
|
||||||
<p><sl-textarea label="Notes" name="notes"></sl-textarea></p>
|
|
||||||
<sl-details summary="Take Photo" id="photo-box">
|
|
||||||
<p class="fallback">Your browser does not support taking photos.</p>
|
|
||||||
<div id="photo-view" class="invisible">
|
|
||||||
<div class="workspace">
|
|
||||||
<video class="invisible"></video>
|
|
||||||
</div>
|
|
||||||
<div class="buttons">
|
|
||||||
<sl-tooltip content="Take Photo">
|
|
||||||
<sl-icon-button name="camera" label="Take Photo"></sl-icon-button>
|
|
||||||
</sl-tooltip>
|
|
||||||
<sl-tooltip content="Start Over">
|
|
||||||
<sl-icon-button
|
|
||||||
name="trash"
|
|
||||||
label="Start Over"
|
|
||||||
></sl-icon-button>
|
|
||||||
</sl-tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</sl-details>
|
|
||||||
<footer>
|
|
||||||
<sl-button type="reset" variant="secondary">Reset</sl-button
|
|
||||||
><sl-button type="submit" variant="primary">Submit</sl-button>
|
|
||||||
</footer>
|
|
||||||
</form>
|
|
||||||
{% endblock %}
|
|
||||||
{% block scripts %}
|
|
||||||
<script src="/static/transaction.js"></script>
|
|
||||||
{% endblock %}
|
|
Loading…
Reference in New Issue