Compare commits
No commits in common. "python" and "master" have entirely different histories.
|
@ -1,5 +1,8 @@
|
||||||
*
|
*
|
||||||
!.git/
|
!Cargo.*
|
||||||
!index.html
|
!.sqlx/
|
||||||
!pyproject.toml
|
!js/
|
||||||
!receipts.py
|
!migrations/
|
||||||
|
!src/
|
||||||
|
!sql/
|
||||||
|
!templates/
|
||||||
|
|
|
@ -2,8 +2,25 @@ root = true
|
||||||
|
|
||||||
[*]
|
[*]
|
||||||
end_of_line = lf
|
end_of_line = lf
|
||||||
insert_final_newline = true
|
insert_final_newline =true
|
||||||
|
charset = utf-8
|
||||||
|
|
||||||
[*.html]
|
[*.{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_style = space
|
||||||
indent_size = 4
|
indent_size = 4
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
__pycache__/
|
/Rocket.toml
|
||||||
*.egg-info/
|
/firefly.token
|
||||||
*.py[co]
|
/target
|
||||||
paperless.token
|
/.postgresql
|
||||||
|
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "*.html.tera",
|
||||||
|
"options": {
|
||||||
|
"parser": "html"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "DELETE FROM receipts WHERE id = $1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Int4"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "1b1fccb2e49acf1402b1c473d4eaebd46034214df37c1d5b954a69027c2c037f"
|
||||||
|
}
|
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
|
@ -0,0 +1,57 @@
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
|
@ -0,0 +1,28 @@
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
|
@ -0,0 +1,52 @@
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
|
@ -0,0 +1,53 @@
|
||||||
|
{
|
||||||
|
"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
|
@ -0,0 +1,24 @@
|
||||||
|
[package]
|
||||||
|
name = "receipts"
|
||||||
|
version = "0.1.0"
|
||||||
|
authors = ["Dustin C. Hatch <dustin@hatch.name>"]
|
||||||
|
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]
|
||||||
|
chrono = { version = "0.4.40", default-features = false, features = ["serde"] }
|
||||||
|
graphicsmagick = { version = "0.6.1", features = ["v1_3_38"] }
|
||||||
|
reqwest = { version = "0.12.12", features = ["json"] }
|
||||||
|
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-subscriber = { version = "0.3.19", features = ["env-filter"] }
|
|
@ -4,20 +4,38 @@ RUN --mount=type=cache,target=/var/cache \
|
||||||
microdnf install -y \
|
microdnf install -y \
|
||||||
--setopt persistdir=/var/cache/dnf \
|
--setopt persistdir=/var/cache/dnf \
|
||||||
--setopt install_weak_deps=0 \
|
--setopt install_weak_deps=0 \
|
||||||
git-core \
|
GraphicsMagick-devel \
|
||||||
python3 \
|
cargo \
|
||||||
uv \
|
clang-devel \
|
||||||
&& :
|
openssl-devel \
|
||||||
|
&& :
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /build
|
||||||
|
|
||||||
COPY . .
|
COPY Cargo.* .
|
||||||
|
COPY src src
|
||||||
|
COPY .sqlx .sqlx
|
||||||
|
COPY migrations migrations
|
||||||
|
COPY sql sql
|
||||||
|
|
||||||
ENV UV_PROJECT_ENVIRONMENT=/app
|
RUN --mount=type=cache,target=/root/.cargo \
|
||||||
|
cargo build --release --locked
|
||||||
|
|
||||||
RUN uv sync --no-dev --no-editable
|
FROM git.pyrocufflink.net/containerimages/dch-base AS esbuild
|
||||||
|
|
||||||
RUN cp index.html /app/lib/python*/site-packages/
|
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
|
FROM git.pyrocufflink.net/containerimages/dch-base
|
||||||
|
@ -26,12 +44,19 @@ RUN --mount=type=cache,target=/var/cache \
|
||||||
microdnf install -y \
|
microdnf install -y \
|
||||||
--setopt persistdir=/var/cache/dnf \
|
--setopt persistdir=/var/cache/dnf \
|
||||||
--setopt install_weak_deps=0 \
|
--setopt install_weak_deps=0 \
|
||||||
python3 \
|
GraphicsMagick \
|
||||||
tini \
|
clang-libs \
|
||||||
|
ghostscript \
|
||||||
&& :
|
&& :
|
||||||
|
|
||||||
COPY --from=build /app /app
|
COPY --from=build /build/target/release/receipts /usr/local/bin
|
||||||
|
|
||||||
ENV PATH=/app/bin:/usr/bin
|
COPY --from=esbuild /build/dist /usr/local/share/receipts/static
|
||||||
|
|
||||||
ENTRYPOINT ["tini", "uvicorn", "--", "receipts:app"]
|
COPY templates /usr/local/share/receipts/templates
|
||||||
|
|
||||||
|
WORKDIR /usr/local/share/receipts
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/local/bin/receipts"]
|
||||||
|
|
||||||
|
ENV ROCKET_CONFIG=/etc/receipts/config.toml
|
||||||
|
|
|
@ -0,0 +1,201 @@
|
||||||
|
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.
|
|
@ -0,0 +1,21 @@
|
||||||
|
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.
|
|
@ -0,0 +1,10 @@
|
||||||
|
@Library('containerimages')
|
||||||
|
@Library('dch')
|
||||||
|
_
|
||||||
|
|
||||||
|
buildContainerImage2(project: 'packages', defaultBranch: 'master')
|
||||||
|
stage('Deploy') {
|
||||||
|
when(env.BRANCH_NAME == "master") {
|
||||||
|
kubeRestartDeployment()
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,22 @@
|
||||||
|
#!/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
|
|
@ -0,0 +1,3 @@
|
||||||
|
DB_CONNECTION=sqlite
|
||||||
|
APP_KEY=FVo8gylkwKlgtXbn4hjcdCuekDEbGyl2
|
||||||
|
MAIL_MAILER=log
|
136
index.html
136
index.html
|
@ -1,136 +0,0 @@
|
||||||
<!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 id="upload-form">
|
|
||||||
<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"><button id="submit" type="submit" disabled>Submit</button></div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<dialog id="dialog">
|
|
||||||
<article>
|
|
||||||
<h2 id="dialog-title">[upload result]</h2>
|
|
||||||
<p id="dialog-text">[result details]</p>
|
|
||||||
</article>
|
|
||||||
</dialog>
|
|
||||||
<script>
|
|
||||||
(() => {
|
|
||||||
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 inpDate = document.createElement("input");
|
|
||||||
inpDate.type = "date";
|
|
||||||
inpDate.required = true;
|
|
||||||
inpDate.name = "date[]";
|
|
||||||
el.appendChild(inpDate);
|
|
||||||
const inpNotes = document.createElement("input");
|
|
||||||
inpNotes.name = "notes[]";
|
|
||||||
inpNotes.placeholder = "Notes ...";
|
|
||||||
//el.appendChild(inpNotes);
|
|
||||||
previews.appendChild(el);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const showDialog = (title, text) => {
|
|
||||||
dialog_title.innerText = title;
|
|
||||||
dialog_text.innerText = text;
|
|
||||||
dialog.show();
|
|
||||||
};
|
|
||||||
|
|
||||||
const image = document.getElementById("image");
|
|
||||||
const previews = document.getElementById("previews");
|
|
||||||
const form = document.getElementById("upload-form");
|
|
||||||
const submit = document.getElementById("submit");
|
|
||||||
const dialog = document.getElementById("dialog");
|
|
||||||
const dialog_title = document.getElementById("dialog-title");
|
|
||||||
const dialog_text = document.getElementById("dialog-text");
|
|
||||||
|
|
||||||
image.addEventListener("change", (e) => {
|
|
||||||
clearPreviews();
|
|
||||||
setPreviews();
|
|
||||||
submit.disabled = !image.files;
|
|
||||||
});
|
|
||||||
|
|
||||||
form.addEventListener("reset", () => {
|
|
||||||
submit.disabled = true;
|
|
||||||
clearPreviews();
|
|
||||||
});
|
|
||||||
|
|
||||||
form.addEventListener("submit", (e) => {
|
|
||||||
submit.setAttribute("aria-busy", "true");
|
|
||||||
const data = new FormData(form);
|
|
||||||
fetch("/", {
|
|
||||||
method: "POST",
|
|
||||||
body: data,
|
|
||||||
})
|
|
||||||
.then((r) => {
|
|
||||||
submit.removeAttribute("aria-busy");
|
|
||||||
if (r.ok) {
|
|
||||||
showDialog(
|
|
||||||
"Upload Success",
|
|
||||||
"Successfully uploaded receipts"
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
r.text().then((msg) => {
|
|
||||||
showDialog(
|
|
||||||
"Upload Failure",
|
|
||||||
`Failed to upload receipts: ${r.statusText}\n${msg}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
form.reset();
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
submit.removeAttribute("aria-busy");
|
|
||||||
showDialog("Upload Failure", e.toString());
|
|
||||||
});
|
|
||||||
e.preventDefault();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener("click", (e) => {
|
|
||||||
if (dialog.open) {
|
|
||||||
const elem = dialog.querySelector("*");
|
|
||||||
if (!elem.contains(e.target)) {
|
|
||||||
dialog.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (image.files.length > 0) {
|
|
||||||
setPreviews();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
dist/
|
||||||
|
node_modules/
|
|
@ -0,0 +1,16 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,34 @@
|
||||||
|
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);
|
||||||
|
}
|
|
@ -0,0 +1,58 @@
|
||||||
|
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();
|
||||||
|
}
|
|
@ -0,0 +1,177 @@
|
||||||
|
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);
|
|
@ -0,0 +1,90 @@
|
||||||
|
: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);
|
||||||
|
}
|
|
@ -0,0 +1,20 @@
|
||||||
|
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.
After Width: | Height: | Size: 2.5 KiB |
Binary file not shown.
After Width: | Height: | Size: 5.1 KiB |
Binary file not shown.
After Width: | Height: | Size: 3.0 KiB |
Binary file not shown.
After Width: | Height: | Size: 5.6 KiB |
Binary file not shown.
After Width: | Height: | Size: 9.2 KiB |
Binary file not shown.
After Width: | Height: | Size: 17 KiB |
|
@ -0,0 +1,648 @@
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"name": "receipts",
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"@shoelace-style/shoelace": "^2.20.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"esbuild": "^0.25.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "node build.js"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,176 @@
|
||||||
|
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();
|
|
@ -0,0 +1,89 @@
|
||||||
|
import "@shoelace-style/shoelace/dist/components/alert/alert.js";
|
||||||
|
import "@shoelace-style/shoelace/dist/components/button/button.js";
|
||||||
|
import "@shoelace-style/shoelace/dist/components/card/card.js";
|
||||||
|
import "@shoelace-style/shoelace/dist/components/dialog/dialog.js";
|
||||||
|
import "@shoelace-style/shoelace/dist/components/icon/icon.js";
|
||||||
|
import "@shoelace-style/shoelace/dist/components/icon-button/icon-button.js";
|
||||||
|
|
||||||
|
import "./shoelace.js";
|
||||||
|
|
||||||
|
import SlDialog from "@shoelace-style/shoelace/dist/components/dialog/dialog.js";
|
||||||
|
|
||||||
|
import { notify, notifyError } from "./alert.js";
|
||||||
|
import { getResponseError } from "./ajaxUtil.js";
|
||||||
|
|
||||||
|
const dlgDelete = document.getElementById("confirm-delete") as SlDialog;
|
||||||
|
|
||||||
|
const confirmDelete = (
|
||||||
|
id: string,
|
||||||
|
amount: string,
|
||||||
|
date: string,
|
||||||
|
vendor: string,
|
||||||
|
) => {
|
||||||
|
dlgDelete.querySelector(".amount")!.textContent = amount;
|
||||||
|
dlgDelete.querySelector(".date")!.textContent = date;
|
||||||
|
dlgDelete.querySelector(".receipt-id")!.textContent = id;
|
||||||
|
dlgDelete.querySelector(".vendor")!.textContent = vendor;
|
||||||
|
dlgDelete.show();
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteReceipt = async (receiptId: string): Promise<boolean> => {
|
||||||
|
let r: Response;
|
||||||
|
try {
|
||||||
|
r = await fetch(`/receipts/${receiptId}`, { method: "DELETE" });
|
||||||
|
} catch (e) {
|
||||||
|
notifyError(e.toString());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (r.ok) {
|
||||||
|
notify(`Deleted receipt ${receiptId}`);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
const err = await getResponseError(r);
|
||||||
|
notifyError(err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.querySelectorAll(".receipt-card").forEach((card) => {
|
||||||
|
const amount = card.querySelector(".amount")!.textContent!;
|
||||||
|
const date = (card.querySelector(".date") as HTMLElement).dataset.date!;
|
||||||
|
const vendor = card.querySelector(".vendor")!.textContent!;
|
||||||
|
const btn = card.querySelector("sl-icon-button[name='trash']");
|
||||||
|
if (btn) {
|
||||||
|
btn.addEventListener("click", (evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
confirmDelete(
|
||||||
|
(card as HTMLElement).dataset.receiptId!,
|
||||||
|
amount,
|
||||||
|
date,
|
||||||
|
vendor,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
dlgDelete
|
||||||
|
.querySelector("sl-button[aria-label='No']")!
|
||||||
|
.addEventListener("click", () => {
|
||||||
|
dlgDelete.hide();
|
||||||
|
});
|
||||||
|
|
||||||
|
dlgDelete
|
||||||
|
.querySelector("sl-button[aria-label='Yes']")!
|
||||||
|
.addEventListener("click", async () => {
|
||||||
|
dlgDelete.hide();
|
||||||
|
const receiptId = dlgDelete.querySelector(".receipt-id")?.textContent;
|
||||||
|
if (receiptId) {
|
||||||
|
const success = await deleteReceipt(receiptId);
|
||||||
|
if (success) {
|
||||||
|
const card = document.querySelector(
|
||||||
|
`sl-card[data-receipt-id="${receiptId}"]`,
|
||||||
|
) as HTMLElement;
|
||||||
|
card.style.opacity = "0";
|
||||||
|
setTimeout(() => {
|
||||||
|
card.remove();
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
|
@ -0,0 +1,6 @@
|
||||||
|
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";
|
|
@ -0,0 +1,2 @@
|
||||||
|
import { setBasePath } from "@shoelace-style/shoelace/dist/utilities/base-path.js";
|
||||||
|
setBasePath("/static/shoelace/");
|
|
@ -0,0 +1,19 @@
|
||||||
|
.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;
|
||||||
|
}
|
|
@ -0,0 +1,183 @@
|
||||||
|
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;
|
||||||
|
});
|
|
@ -0,0 +1,9 @@
|
||||||
|
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,25 +0,0 @@
|
||||||
[project]
|
|
||||||
name = "receipts"
|
|
||||||
authors = [{name = "Dustin C. Hatch", email = "dustin@hatch.name"}]
|
|
||||||
requires-python = ">=3.12"
|
|
||||||
dependencies = [
|
|
||||||
"fastapi>=0.115.10",
|
|
||||||
"httpx>=0.28.1",
|
|
||||||
"python-multipart>=0.0.20",
|
|
||||||
"uvicorn>=0.34.0",
|
|
||||||
]
|
|
||||||
dynamic = ["version"]
|
|
||||||
|
|
||||||
[build-system]
|
|
||||||
requires = ["setuptools", "setuptools-scm"]
|
|
||||||
build-backend = "setuptools.build_meta"
|
|
||||||
|
|
||||||
[tool.setuptools_scm]
|
|
||||||
|
|
||||||
[tool.pyright]
|
|
||||||
venvPath = '.'
|
|
||||||
venv = '.venv'
|
|
||||||
|
|
||||||
[tool.black]
|
|
||||||
line-length = 79
|
|
||||||
skip-string-normalization = true
|
|
133
receipts.py
133
receipts.py
|
@ -1,133 +0,0 @@
|
||||||
import contextlib
|
|
||||||
import datetime
|
|
||||||
import importlib.metadata
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Annotated, BinaryIO, Optional, Self, Type
|
|
||||||
from types import TracebackType
|
|
||||||
|
|
||||||
import fastapi
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
|
|
||||||
__dist__ = importlib.metadata.metadata(__name__)
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
PAPERLESS_TOKEN: str
|
|
||||||
PAPERLESS_URL = os.environ['PAPERLESS_URL'].rstrip('/')
|
|
||||||
|
|
||||||
|
|
||||||
router = fastapi.APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
class Paperless:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.client: Optional[httpx.AsyncClient] = None
|
|
||||||
|
|
||||||
async def __aenter__(self) -> Self:
|
|
||||||
self.client = httpx.AsyncClient()
|
|
||||||
return self
|
|
||||||
|
|
||||||
async def __aexit__(
|
|
||||||
self,
|
|
||||||
exc_type: Optional[Type[Exception]],
|
|
||||||
exc_value: Optional[Exception],
|
|
||||||
tb: Optional[TracebackType],
|
|
||||||
) -> None:
|
|
||||||
if self.client:
|
|
||||||
await self.client.aclose()
|
|
||||||
self.client = None
|
|
||||||
|
|
||||||
async def upload(
|
|
||||||
self, filename: str, image: BinaryIO, date: datetime.date
|
|
||||||
) -> str:
|
|
||||||
assert self.client
|
|
||||||
log.debug('Sending %s to paperless', filename)
|
|
||||||
r = await self.client.post(
|
|
||||||
f'{PAPERLESS_URL}/api/documents/post_document/',
|
|
||||||
headers={
|
|
||||||
'Authorization': f'Token {PAPERLESS_TOKEN}',
|
|
||||||
},
|
|
||||||
files={
|
|
||||||
'document': (filename, image),
|
|
||||||
},
|
|
||||||
data={
|
|
||||||
'created': date.strftime('%Y-%m-%d'),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
r.raise_for_status()
|
|
||||||
task_id = r.text.strip()
|
|
||||||
log.info(
|
|
||||||
'Successfully uploaded %s to paperless; started consume task %s',
|
|
||||||
filename,
|
|
||||||
task_id,
|
|
||||||
)
|
|
||||||
return task_id
|
|
||||||
|
|
||||||
|
|
||||||
@router.get('/', response_class=fastapi.responses.HTMLResponse)
|
|
||||||
def get_form():
|
|
||||||
path = Path(__file__).with_name('index.html')
|
|
||||||
try:
|
|
||||||
f = path.open('r', encoding='utf-8')
|
|
||||||
except FileNotFoundError:
|
|
||||||
raise fastapi.HTTPException(
|
|
||||||
status_code=fastapi.status.HTTP_404_NOT_FOUND,
|
|
||||||
)
|
|
||||||
with path.open('r', encoding='utf-8') as f:
|
|
||||||
return f.read()
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
'/',
|
|
||||||
response_class=fastapi.responses.PlainTextResponse,
|
|
||||||
status_code=fastapi.status.HTTP_204_NO_CONTENT,
|
|
||||||
)
|
|
||||||
async def upload_receipts(
|
|
||||||
images: Annotated[list[fastapi.UploadFile], fastapi.File(alias='image[]')],
|
|
||||||
dates: Annotated[list[datetime.date], fastapi.Form(alias='date[]')],
|
|
||||||
# notes: Annotated[list[str], fastapi.Form(alias='notes[]')],
|
|
||||||
):
|
|
||||||
if len(dates) != len(images):
|
|
||||||
msg = (
|
|
||||||
f'Number of uploaded images ({len(images)})'
|
|
||||||
f' does not match number of date fields ({len(dates)})'
|
|
||||||
)
|
|
||||||
log.warning('%s', msg)
|
|
||||||
raise fastapi.HTTPException(
|
|
||||||
status_code=fastapi.status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=msg,
|
|
||||||
)
|
|
||||||
failed = False
|
|
||||||
async with Paperless() as paperless:
|
|
||||||
for idx, image in enumerate(images):
|
|
||||||
date = dates[idx]
|
|
||||||
filename = image.filename or f'image{idx}'
|
|
||||||
try:
|
|
||||||
await paperless.upload(filename, image.file, date)
|
|
||||||
except Exception as e:
|
|
||||||
log.error('Failed to send %s to Paperless: %s', filename, e)
|
|
||||||
failed = True
|
|
||||||
if failed:
|
|
||||||
raise fastapi.HTTPException(
|
|
||||||
status_code=fastapi.status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@contextlib.asynccontextmanager
|
|
||||||
async def lifespan(app: fastapi.FastAPI):
|
|
||||||
global PAPERLESS_TOKEN
|
|
||||||
PAPERLESS_TOKEN = (
|
|
||||||
Path(os.environ['PAPERLESS_TOKEN_FILE']).read_text().strip()
|
|
||||||
)
|
|
||||||
yield
|
|
||||||
|
|
||||||
|
|
||||||
app = fastapi.FastAPI(
|
|
||||||
version=__dist__['version'],
|
|
||||||
lifespan=lifespan,
|
|
||||||
)
|
|
||||||
app.include_router(router)
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
match_block_trailing_comma = true
|
||||||
|
max_width = 79
|
|
@ -0,0 +1,4 @@
|
||||||
|
SELECT
|
||||||
|
count(id) AS "count!"
|
||||||
|
FROM
|
||||||
|
receipts
|
|
@ -0,0 +1,6 @@
|
||||||
|
SELECT
|
||||||
|
id, vendor, date, amount, notes, filename
|
||||||
|
FROM
|
||||||
|
receipts
|
||||||
|
WHERE
|
||||||
|
id = $1
|
|
@ -0,0 +1,7 @@
|
||||||
|
INSERT INTO receipts (
|
||||||
|
vendor, date, amount, notes, filename, image
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3, $4, $5, $6
|
||||||
|
)
|
||||||
|
RETURNING
|
||||||
|
id, vendor, date, amount, notes, filename
|
|
@ -0,0 +1,9 @@
|
||||||
|
SELECT
|
||||||
|
id, vendor, date, amount, notes, filename
|
||||||
|
FROM
|
||||||
|
receipts
|
||||||
|
ORDER BY
|
||||||
|
date DESC,
|
||||||
|
id DESC
|
||||||
|
LIMIT $1
|
||||||
|
OFFSET $2
|
|
@ -0,0 +1,22 @@
|
||||||
|
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()
|
||||||
|
}
|
|
@ -0,0 +1,322 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,46 @@
|
||||||
|
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(),
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,118 @@
|
||||||
|
mod config;
|
||||||
|
mod firefly;
|
||||||
|
mod imaging;
|
||||||
|
mod receipts;
|
||||||
|
mod routes;
|
||||||
|
mod transactions;
|
||||||
|
|
||||||
|
use rocket::fairing::{self, AdHoc};
|
||||||
|
use rocket::fs::FileServer;
|
||||||
|
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()
|
||||||
|
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
||||||
|
.with_writer(std::io::stderr)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
graphicsmagick::initialize();
|
||||||
|
|
||||||
|
let rocket = rocket::build();
|
||||||
|
let figment = rocket.figment();
|
||||||
|
|
||||||
|
let config: Config = figment.extract().unwrap();
|
||||||
|
|
||||||
|
let ctx = match Context::init(config) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to initialize application context: {}", e);
|
||||||
|
std::process::exit(1);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
debug!("Using Firefly III URL {}", &ctx.firefly.url());
|
||||||
|
|
||||||
|
rocket
|
||||||
|
.manage(ctx)
|
||||||
|
.mount("/", rocket::routes![index])
|
||||||
|
.mount("/transactions", routes::transactions::routes())
|
||||||
|
.mount("/receipts", routes::receipts::routes())
|
||||||
|
.mount("/static", FileServer::from("static"))
|
||||||
|
.attach(Template::fairing())
|
||||||
|
.attach(Database::init())
|
||||||
|
.attach(AdHoc::try_on_ignite("Migrate Database", run_migrations))
|
||||||
|
}
|
|
@ -0,0 +1,205 @@
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,2 @@
|
||||||
|
pub mod receipts;
|
||||||
|
pub mod transactions;
|
|
@ -0,0 +1,382 @@
|
||||||
|
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,
|
||||||
|
]
|
||||||
|
}
|
|
@ -0,0 +1,45 @@
|
||||||
|
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]
|
||||||
|
}
|
|
@ -0,0 +1,52 @@
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
#!/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
|
|
@ -0,0 +1,10 @@
|
||||||
|
#!/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
|
|
@ -0,0 +1,24 @@
|
||||||
|
<!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>
|
|
@ -0,0 +1,6 @@
|
||||||
|
{% extends "base" %} {% block head %}
|
||||||
|
<title>Error</title>
|
||||||
|
{% endblock %} {% block main %}
|
||||||
|
<h1>Error</h1>
|
||||||
|
<p style="color: var(--sl-color-red-600)">{{ error }}</p>
|
||||||
|
{% endblock %}
|
|
@ -0,0 +1,79 @@
|
||||||
|
{% 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 %}
|
|
@ -0,0 +1,155 @@
|
||||||
|
{% 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 %}
|
|
@ -0,0 +1,83 @@
|
||||||
|
{% 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 %}
|
|
@ -0,0 +1,28 @@
|
||||||
|
{% 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 %}
|
|
@ -0,0 +1,59 @@
|
||||||
|
{% 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