Compare commits

..

No commits in common. "3c181c2e4595968e51622872376ecf55fdf59d75" and "0fc5b07f5edffef49426d3f8b7d1792a0e49cfbc" have entirely different histories.

62 changed files with 807 additions and 19278 deletions

View file

@ -1,50 +0,0 @@
name: Deploy Vaadin App
on:
push:
branches:
- prod
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up JDK 22
uses: actions/setup-java@v3
with:
java-version: '22'
distribution: 'temurin'
- name: Build JAR
run: ./mvnw clean package -Pproduction -DskipTests
- name: Set up SSH key
uses: webfactory/ssh-agent@v0.5.3
with:
ssh-private-key: ${{ secrets.SSH_KEY }}
#- name: Clean deployment directory on server
# run: ssh -o StrictHostKeyChecking=no ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} 'rm -rf /home/${{ secrets.SSH_USER }}/munera/*'
- name: Upload JAR to server
run: scp -o StrictHostKeyChecking=no target/munera-1.0-SNAPSHOT.jar ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:/home/${{ secrets.SSH_USER }}/munera
- name: Upload Dockerfile to server
run: scp -o StrictHostKeyChecking=no Dockerfile ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:/home/${{ secrets.SSH_USER }}/munera
- name: Upload docker-compose.yml to server
run: scp -o StrictHostKeyChecking=no docker-compose.yml ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:/home/${{ secrets.SSH_USER }}/munera
- name: Stop Docker containers on server
run: ssh -o StrictHostKeyChecking=no ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} 'cd /home/${{ secrets.SSH_USER }}/munera && docker-compose down'
- name: Start Docker containers on server
run: |
ssh -o StrictHostKeyChecking=no ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} << 'ENDSSH'
cd /home/${{ secrets.SSH_USER }}/munera
docker-compose up --build -d
ENDSSH

View file

@ -1,41 +0,0 @@
name: Deploy to Debian Server
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout the code
uses: actions/checkout@v2
- name: Set up SSH
uses: webfactory/ssh-agent@v0.5.3
with:
ssh-private-key: ${{ secrets.SSH_KEY }}
- name: Copy files to server
run: |
scp -o StrictHostKeyChecking=no docker-compose.yml Dockerfile ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:/home/${{ secrets.SSH_USER }}/munera/
scp -o StrictHostKeyChecking=no target/munera-1.0-SNAPSHOT.jar ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:/home/${{ secrets.SSH_USER }}/munera/
- name: Deploy on server
run: |
ssh -o StrictHostKeyChecking=no ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} << 'EOF'
cd /home/${{ secrets.SSH_USER }}/munera
echo "DB_ADDRESS=${DB_ADDRESS}" > .env
echo "DB_USER=${DB_USER}" >> .env
echo "DB_PASSWORD=${DB_PASSWORD}" >> .env
echo "DB_NAME=${DB_NAME}" >> .env
docker-compose down
docker-compose up -d --build
EOF
env:
DB_ADDRESS: ${{ secrets.DB_ADDRESS }}
DB_USER: ${{ secrets.DB_USER }}
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
DB_NAME: ${{ secrets.DB_NAME }}

6
.gitignore vendored
View file

@ -4,12 +4,6 @@
.settings .settings
.project .project
.classpath .classpath
.env
src/main/resources/*.sql
# Ignore profile-specific properties files
application-*.properties
*.iml *.iml
.DS_Store .DS_Store

View file

@ -1,13 +0,0 @@
FROM openjdk:21-jdk-slim
# Set the working directory
WORKDIR /app
# Copy the JAR file into the container
COPY munera-1.0-SNAPSHOT.jar /app/munera-1.0-SNAPSHOT.jar
# Expose the port the app runs on
EXPOSE 8080
# Run the JAR file
CMD ["java", "-jar", "/app/munera-1.0-SNAPSHOT.jar"]

661
LICENSE
View file

@ -1,661 +0,0 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.

24
LICENSE.md Normal file
View file

@ -0,0 +1,24 @@
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
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 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.
For more information, please refer to <http://unlicense.org>

148
README.md
View file

@ -2,107 +2,75 @@
## Overview ## Overview
**Munera** is your go-to companion for managing expenses efficiently and effortlessly, whether you're tracking daily expenditures, managing recurring expenses, or keeping tabs on creditors and debtors. Munera is a companion for managing expenses efficiently and effortlessly, whether you're tracking daily expenditures, managing recurring expenses, or keeping tabs on creditors and debtors.
Munera is built using **Java** with the **Spring** framework. It utilizes **Vaadin Flow** for creating modern web applications. The application uses a **PostgreSQL** database for data storage. **Maven** is employed as the build tool, while **JUnit** and **Mockito** are used for unit testing. **Spring Security** handles authentication, and **Apache Commons** to export data.
### Dashboard
![Dashboard](src/main/resources/pictures/dashboard.png) ![Dashboard](src/main/resources/pictures/dashboard.png)
![Grid](src/main/resources/pictures/grid.png)
### Expenses Grid
![Expenses Grid](src/main/resources/pictures/grid.png)
### People Grid
![People Grid](src/main/resources/pictures/people.png)
## Features
### 1. Expense Management ### 1. Expense Management
Easily create, read, update, and delete expenses with comprehensive details, including: - Create, read, update, and delete expenses with the following details:
- Name
- Date
- Category
- Cost
- Description
- Period Interval (1, 2, 3, etc)
- Period Unit (days, weeks, months)
- Creditors and debtors
- Status
-
- Create, read, update and delete categories
- Create, read, update and delete people
- Create, read, update and delete events
- **Name**: The name of the expense. ## Completed Features
- **Date**: The date the expense occurred. - [x] Create categories with CRUD operations
- **Category**: Assign categories to expenses for easier tracking. - [x] Create concept of creditors and debtors for each expense
- **Cost**: The amount spent. - [x] Insert expenses of the past and of the future
- **Description**: Additional notes or details about the expense. - [x] Have an option to set up recurring expenses
- **Period Interval**: Set the recurrence interval (e.g., 1, 2, 3, etc.). - [x] Creditors and debtors total expenses
- **Period Unit**: Specify the unit for the period interval (days, weeks, months). - [x] Setup status for each expense
- **Payer and Beneficiary**: Record who paid and who benefited from the expense. - [x] Create mechanism to change status when expense gets paid
- **Date of Payment**: The date when the payment was made. - [x] Create labels to show if a person is in debit or credit with you
- **Status of Expense**: Track the status of the expense (e.g., "Paid to Me", "Owed to Me", "Paid by Me", "Owed by Me"). - [x] First tries with widgets in a dashboard view
- [x] An expense cant have the same person as both creditor and debtors
## Next Steps
### 2. Category Management 1. **Filtering and Sorting**
- Keep implementing sorting on more columns, start implementing filtering
- Specification
- Manage categories for better organization of expenses. 2. **Weekly and Monthly Summaries**
- Default categories are initialized on the first build of the application. - Create functionality to generate weekly and monthly summaries, including filtering and sorting options.
- ~~Create a dashboard or log of "next expenses" that lists the next recurring expenses that you expect to receive~~
### 3. People Management 3. **Reports for Creditors and Debtors**
- ~~Develop reports outlining debts or credits for each creditor and debtor to provide users with a comprehensive overview.~~
- ~~CRUD operations for creditors and debtors~~
- Track expenses related to people and generate reports on amounts owed or owing. 4. **Create a way to set the currency of each expense**
- It should be possible in the form of an expense
- The symbol should be added to the cost column
- If possible, the calculations should take into account the different currencies
### 4. Reporting and Analysis 5. **Events**
- ~~Options to create events in which to put expenses (vacations, congress, etc)~~
- ~~Each event has a number of people connected to it, expenses can be added to these people~~
- A reports tells which people need to give/take money to which people in order to be even
- Generate reports to view expenses and debts by category or person. 6. **Misc**
- Visualize your expenses with built-in graphs. - PeriodUnit and Interval need to be implemented with a scheduler
- Graphs could use more work, maybe some filtering?
- More validation in form
- ~~Login page~~
- email setup
- Migration tool for DB changes in prod
### 5. Data Export ## Known Issues
- Export your expense data to CSV format for external use and backup. - PeopleVIew dosent refresh after an edit operation anymore
- Form still needs more validation when empty, some entities can be created with all null values, even the ones that have constraints throw SQL errors, they need to be gracefully handled.
### 6. Upcoming Features - Errors need to be caught and handled
- Graphs still need ~~a lot of~~ **some** improvements
- **Income Tracking**: An upcoming feature to manage and track income efficiently. - ~~ExpenseView dosent refresh after an edit operation anymore~~
## Installation
You can self-host Munera in two ways:
### Option 1: Manual Installation
1. **Compile and Run**: Download the source code, compile, and run the application manually with your preferred SQL database.
2. **Default Setup**: The first build will automatically create default categories and an admin user, as specified in the `application.properties` file.
### Option 2: Docker Installation
1. **Dockerized Setup**: Use the provided `Dockerfile` and `docker-compose.yml` files to deploy Munera using Docker.
2. **Configuration**: The first build will set up default categories and an admin user based on the environment variables in `docker-compose.yml`.
## Getting Started
Follow the steps below to get started with Munera:
1. **Clone the Repository**:
```bash
git clone https://github.com/yourusername/munera.git
cd munera
```
2. **Manual Installation**:
- Configure your SQL database.
- Adjust settings in `application.properties`.
- Build and run the application with Maven:
```bash
mvn clean install
mvn spring-boot:run
```
3. **Docker Installation**:
- Ensure Docker is installed and running.
- Use Docker Compose to set up Munera:
```bash
docker-compose up --build
```
## License
Munera is licensed under the **GNU Affero General Public License** (AGPL) Version 3. See the LICENSE file for details.
## Feedback and Contributions
Contributions and feedback are welcome! Feel free to submit a pull request or open an issue on the [GitHub repository](https://github.com/filippo-ferrari/munera).

View file

@ -1,34 +0,0 @@
version: '3.8'
services:
vaadin-app:
image: vaadin-app
build: .
ports:
- "8080:8080"
environment:
SPRING_DATASOURCE_URL: ${DB_ADDRESS}/${DB_NAME}
SPRING_DATASOURCE_USERNAME: ${DB_USER}
SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD}
ADMIN_USERNAME: ${ADMIN_USERNAME}
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
ADMIN_FIRST_NAME: ${ADMIN_FIRST_NAME}
ADMIN_LAST_NAME: ${ADMIN_LAST_NAME}
ADMIN_ROLES: ${ADMIN_ROLES}
ADMIN_EMAIL: ${ADMIN_EMAIL}
depends_on:
- db
db:
image: postgres:13
environment:
POSTGRES_DB: ${DB_NAME}
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
ports:
- "5432:5432"
volumes:
- db_data:/var/lib/postgresql/data
volumes:
db_data:

15654
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,290 +0,0 @@
{
"name": "no-name",
"license": "UNLICENSED",
"type": "module",
"dependencies": {
"@polymer/iron-iconset-svg": "^3.0.1",
"@polymer/paper-button": "^3.0.1",
"@polymer/paper-icon-button": "^3.0.2",
"@polymer/polymer": "3.5.1",
"@vaadin/a11y-base": "24.3.13",
"@vaadin/accordion": "24.3.13",
"@vaadin/app-layout": "24.3.13",
"@vaadin/avatar": "24.3.13",
"@vaadin/avatar-group": "24.3.13",
"@vaadin/board": "24.3.13",
"@vaadin/bundles": "24.3.13",
"@vaadin/button": "24.3.13",
"@vaadin/charts": "24.3.13",
"@vaadin/checkbox": "24.3.13",
"@vaadin/checkbox-group": "24.3.13",
"@vaadin/combo-box": "24.3.13",
"@vaadin/common-frontend": "0.0.19",
"@vaadin/component-base": "24.3.13",
"@vaadin/confirm-dialog": "24.3.13",
"@vaadin/context-menu": "24.3.13",
"@vaadin/cookie-consent": "24.3.13",
"@vaadin/crud": "24.3.13",
"@vaadin/custom-field": "24.3.13",
"@vaadin/date-picker": "24.3.13",
"@vaadin/date-time-picker": "24.3.13",
"@vaadin/details": "24.3.13",
"@vaadin/dialog": "24.3.13",
"@vaadin/email-field": "24.3.13",
"@vaadin/field-base": "24.3.13",
"@vaadin/field-highlighter": "24.3.13",
"@vaadin/form-layout": "24.3.13",
"@vaadin/grid": "24.3.13",
"@vaadin/grid-pro": "24.3.13",
"@vaadin/horizontal-layout": "24.3.13",
"@vaadin/icon": "24.3.13",
"@vaadin/icons": "24.3.13",
"@vaadin/input-container": "24.3.13",
"@vaadin/integer-field": "24.3.13",
"@vaadin/item": "24.3.13",
"@vaadin/list-box": "24.3.13",
"@vaadin/lit-renderer": "24.3.13",
"@vaadin/login": "24.3.13",
"@vaadin/map": "24.3.13",
"@vaadin/menu-bar": "24.3.13",
"@vaadin/message-input": "24.3.13",
"@vaadin/message-list": "24.3.13",
"@vaadin/multi-select-combo-box": "24.3.13",
"@vaadin/notification": "24.3.13",
"@vaadin/number-field": "24.3.13",
"@vaadin/overlay": "24.3.13",
"@vaadin/password-field": "24.3.13",
"@vaadin/polymer-legacy-adapter": "24.3.13",
"@vaadin/progress-bar": "24.3.13",
"@vaadin/radio-group": "24.3.13",
"@vaadin/rich-text-editor": "24.3.13",
"@vaadin/router": "1.7.5",
"@vaadin/scroller": "24.3.13",
"@vaadin/select": "24.3.13",
"@vaadin/side-nav": "24.3.13",
"@vaadin/split-layout": "24.3.13",
"@vaadin/tabs": "24.3.13",
"@vaadin/tabsheet": "24.3.13",
"@vaadin/text-area": "24.3.13",
"@vaadin/text-field": "24.3.13",
"@vaadin/time-picker": "24.3.13",
"@vaadin/tooltip": "24.3.13",
"@vaadin/upload": "24.3.13",
"@vaadin/vaadin-development-mode-detector": "2.0.7",
"@vaadin/vaadin-lumo-styles": "24.3.13",
"@vaadin/vaadin-material-styles": "24.3.13",
"@vaadin/vaadin-themable-mixin": "24.3.13",
"@vaadin/vaadin-usage-statistics": "2.1.2",
"@vaadin/vertical-layout": "24.3.13",
"@vaadin/virtual-list": "24.3.13",
"construct-style-sheets-polyfill": "3.1.0",
"date-fns": "2.29.3",
"lit": "3.1.2",
"lit-element": "^3.2.2",
"lit-html": "2.4.0",
"proj4": "2.9.2"
},
"devDependencies": {
"@rollup/plugin-replace": "5.0.5",
"@rollup/pluginutils": "5.1.0",
"@vitejs/plugin-react": "4.2.1",
"@vitejs/plugin-react-swc": "3.5.0",
"async": "3.2.4",
"glob": "10.3.3",
"rollup-plugin-brotli": "3.1.0",
"rollup-plugin-visualizer": "5.12.0",
"strip-css-comments": "5.0.0",
"transform-ast": "2.4.4",
"typescript": "5.3.3",
"vite": "5.1.7",
"vite-plugin-checker": "0.6.4",
"workbox-build": "7.0.0",
"workbox-core": "7.0.0",
"workbox-precaching": "7.0.0"
},
"vaadin": {
"dependencies": {
"@polymer/iron-iconset-svg": "^3.0.1",
"@polymer/paper-button": "^3.0.1",
"@polymer/paper-icon-button": "^3.0.2",
"@polymer/polymer": "3.5.1",
"@vaadin/a11y-base": "24.3.13",
"@vaadin/accordion": "24.3.13",
"@vaadin/app-layout": "24.3.13",
"@vaadin/avatar": "24.3.13",
"@vaadin/avatar-group": "24.3.13",
"@vaadin/board": "24.3.13",
"@vaadin/bundles": "24.3.13",
"@vaadin/button": "24.3.13",
"@vaadin/charts": "24.3.13",
"@vaadin/checkbox": "24.3.13",
"@vaadin/checkbox-group": "24.3.13",
"@vaadin/combo-box": "24.3.13",
"@vaadin/common-frontend": "0.0.19",
"@vaadin/component-base": "24.3.13",
"@vaadin/confirm-dialog": "24.3.13",
"@vaadin/context-menu": "24.3.13",
"@vaadin/cookie-consent": "24.3.13",
"@vaadin/crud": "24.3.13",
"@vaadin/custom-field": "24.3.13",
"@vaadin/date-picker": "24.3.13",
"@vaadin/date-time-picker": "24.3.13",
"@vaadin/details": "24.3.13",
"@vaadin/dialog": "24.3.13",
"@vaadin/email-field": "24.3.13",
"@vaadin/field-base": "24.3.13",
"@vaadin/field-highlighter": "24.3.13",
"@vaadin/form-layout": "24.3.13",
"@vaadin/grid": "24.3.13",
"@vaadin/grid-pro": "24.3.13",
"@vaadin/horizontal-layout": "24.3.13",
"@vaadin/icon": "24.3.13",
"@vaadin/icons": "24.3.13",
"@vaadin/input-container": "24.3.13",
"@vaadin/integer-field": "24.3.13",
"@vaadin/item": "24.3.13",
"@vaadin/list-box": "24.3.13",
"@vaadin/lit-renderer": "24.3.13",
"@vaadin/login": "24.3.13",
"@vaadin/map": "24.3.13",
"@vaadin/menu-bar": "24.3.13",
"@vaadin/message-input": "24.3.13",
"@vaadin/message-list": "24.3.13",
"@vaadin/multi-select-combo-box": "24.3.13",
"@vaadin/notification": "24.3.13",
"@vaadin/number-field": "24.3.13",
"@vaadin/overlay": "24.3.13",
"@vaadin/password-field": "24.3.13",
"@vaadin/polymer-legacy-adapter": "24.3.13",
"@vaadin/progress-bar": "24.3.13",
"@vaadin/radio-group": "24.3.13",
"@vaadin/rich-text-editor": "24.3.13",
"@vaadin/router": "1.7.5",
"@vaadin/scroller": "24.3.13",
"@vaadin/select": "24.3.13",
"@vaadin/side-nav": "24.3.13",
"@vaadin/split-layout": "24.3.13",
"@vaadin/tabs": "24.3.13",
"@vaadin/tabsheet": "24.3.13",
"@vaadin/text-area": "24.3.13",
"@vaadin/text-field": "24.3.13",
"@vaadin/time-picker": "24.3.13",
"@vaadin/tooltip": "24.3.13",
"@vaadin/upload": "24.3.13",
"@vaadin/vaadin-development-mode-detector": "2.0.7",
"@vaadin/vaadin-lumo-styles": "24.3.13",
"@vaadin/vaadin-material-styles": "24.3.13",
"@vaadin/vaadin-themable-mixin": "24.3.13",
"@vaadin/vaadin-usage-statistics": "2.1.2",
"@vaadin/vertical-layout": "24.3.13",
"@vaadin/virtual-list": "24.3.13",
"construct-style-sheets-polyfill": "3.1.0",
"date-fns": "2.29.3",
"lit": "3.1.2",
"lit-element": "^3.2.2",
"lit-html": "2.4.0",
"proj4": "2.9.2"
},
"devDependencies": {
"@rollup/plugin-replace": "5.0.5",
"@rollup/pluginutils": "5.1.0",
"@vitejs/plugin-react": "4.2.1",
"@vitejs/plugin-react-swc": "3.5.0",
"async": "3.2.4",
"glob": "10.3.3",
"rollup-plugin-brotli": "3.1.0",
"rollup-plugin-visualizer": "5.12.0",
"strip-css-comments": "5.0.0",
"transform-ast": "2.4.4",
"typescript": "5.3.3",
"vite": "5.1.7",
"vite-plugin-checker": "0.6.4",
"workbox-build": "7.0.0",
"workbox-core": "7.0.0",
"workbox-precaching": "7.0.0"
},
"hash": "2b6c6cf25413540c35c7b6c7e348be146ae524e27f18cdc7249a709d05dee8c9"
},
"overrides": {
"@vaadin/bundles": "$@vaadin/bundles",
"@vaadin/a11y-base": "$@vaadin/a11y-base",
"@vaadin/accordion": "$@vaadin/accordion",
"@vaadin/app-layout": "$@vaadin/app-layout",
"@vaadin/avatar": "$@vaadin/avatar",
"@vaadin/avatar-group": "$@vaadin/avatar-group",
"@vaadin/button": "$@vaadin/button",
"@vaadin/checkbox": "$@vaadin/checkbox",
"@vaadin/checkbox-group": "$@vaadin/checkbox-group",
"@vaadin/combo-box": "$@vaadin/combo-box",
"@vaadin/component-base": "$@vaadin/component-base",
"@vaadin/confirm-dialog": "$@vaadin/confirm-dialog",
"@vaadin/context-menu": "$@vaadin/context-menu",
"@vaadin/custom-field": "$@vaadin/custom-field",
"@vaadin/date-picker": "$@vaadin/date-picker",
"@vaadin/date-time-picker": "$@vaadin/date-time-picker",
"@vaadin/details": "$@vaadin/details",
"@vaadin/dialog": "$@vaadin/dialog",
"@vaadin/email-field": "$@vaadin/email-field",
"@vaadin/field-base": "$@vaadin/field-base",
"@vaadin/field-highlighter": "$@vaadin/field-highlighter",
"@vaadin/form-layout": "$@vaadin/form-layout",
"@vaadin/grid": "$@vaadin/grid",
"@vaadin/horizontal-layout": "$@vaadin/horizontal-layout",
"@vaadin/icon": "$@vaadin/icon",
"@vaadin/icons": "$@vaadin/icons",
"@vaadin/input-container": "$@vaadin/input-container",
"@vaadin/integer-field": "$@vaadin/integer-field",
"@vaadin/item": "$@vaadin/item",
"@vaadin/list-box": "$@vaadin/list-box",
"@vaadin/lit-renderer": "$@vaadin/lit-renderer",
"@vaadin/login": "$@vaadin/login",
"@vaadin/menu-bar": "$@vaadin/menu-bar",
"@vaadin/message-input": "$@vaadin/message-input",
"@vaadin/message-list": "$@vaadin/message-list",
"@vaadin/multi-select-combo-box": "$@vaadin/multi-select-combo-box",
"@vaadin/notification": "$@vaadin/notification",
"@vaadin/number-field": "$@vaadin/number-field",
"@vaadin/overlay": "$@vaadin/overlay",
"@vaadin/password-field": "$@vaadin/password-field",
"@vaadin/polymer-legacy-adapter": "$@vaadin/polymer-legacy-adapter",
"@vaadin/progress-bar": "$@vaadin/progress-bar",
"@vaadin/radio-group": "$@vaadin/radio-group",
"@vaadin/scroller": "$@vaadin/scroller",
"@vaadin/select": "$@vaadin/select",
"@vaadin/side-nav": "$@vaadin/side-nav",
"@vaadin/split-layout": "$@vaadin/split-layout",
"@vaadin/tabs": "$@vaadin/tabs",
"@vaadin/tabsheet": "$@vaadin/tabsheet",
"@vaadin/text-area": "$@vaadin/text-area",
"@vaadin/text-field": "$@vaadin/text-field",
"@vaadin/time-picker": "$@vaadin/time-picker",
"@vaadin/tooltip": "$@vaadin/tooltip",
"@vaadin/upload": "$@vaadin/upload",
"@vaadin/vaadin-development-mode-detector": "$@vaadin/vaadin-development-mode-detector",
"@vaadin/vaadin-lumo-styles": "$@vaadin/vaadin-lumo-styles",
"@vaadin/vaadin-material-styles": "$@vaadin/vaadin-material-styles",
"@vaadin/router": "$@vaadin/router",
"@vaadin/vaadin-usage-statistics": "$@vaadin/vaadin-usage-statistics",
"@vaadin/vertical-layout": "$@vaadin/vertical-layout",
"@vaadin/virtual-list": "$@vaadin/virtual-list",
"@vaadin/board": "$@vaadin/board",
"@vaadin/charts": "$@vaadin/charts",
"@vaadin/cookie-consent": "$@vaadin/cookie-consent",
"@vaadin/crud": "$@vaadin/crud",
"@vaadin/grid-pro": "$@vaadin/grid-pro",
"@vaadin/map": "$@vaadin/map",
"@vaadin/rich-text-editor": "$@vaadin/rich-text-editor",
"@vaadin/common-frontend": "$@vaadin/common-frontend",
"construct-style-sheets-polyfill": "$construct-style-sheets-polyfill",
"lit": "$lit",
"@polymer/polymer": "$@polymer/polymer",
"proj4": "$proj4",
"@vaadin/vaadin-themable-mixin": "$@vaadin/vaadin-themable-mixin",
"date-fns": "$date-fns",
"@polymer/iron-iconset-svg": "$@polymer/iron-iconset-svg",
"lit-html": "$lit-html",
"@polymer/paper-icon-button": "$@polymer/paper-icon-button",
"lit-element": "$lit-element",
"@polymer/paper-button": "$@polymer/paper-button"
}
}

View file

@ -52,12 +52,6 @@
<groupId>com.vaadin</groupId> <groupId>com.vaadin</groupId>
<artifactId>vaadin-spring-boot-starter</artifactId> <artifactId>vaadin-spring-boot-starter</artifactId>
</dependency> </dependency>
<!-- https://mvnrepository.com/artifact/org.apache.commons/commons-csv -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-csv</artifactId>
<version>1.11.0</version>
</dependency>
<dependency> <dependency>
<groupId>org.vaadin.klaudeta</groupId> <groupId>org.vaadin.klaudeta</groupId>
<artifactId>grid-pagination</artifactId> <artifactId>grid-pagination</artifactId>

View file

@ -1,32 +0,0 @@
This directory is automatically generated by Vaadin and contains the pre-compiled
frontend files/resources for your project (frontend development bundle).
It should be added to Version Control System and committed, so that other developers
do not have to compile it again.
Frontend development bundle is automatically updated when needed:
- an npm/pnpm package is added with @NpmPackage or directly into package.json
- CSS, JavaScript or TypeScript files are added with @CssImport, @JsModule or @JavaScript
- Vaadin add-on with front-end customizations is added
- Custom theme imports/assets added into 'theme.json' file
- Exported web component is added.
If your project development needs a hot deployment of the frontend changes,
you can switch Flow to use Vite development server (default in Vaadin 23.3 and earlier versions):
- set `vaadin.frontend.hotdeploy=true` in `application.properties`
- configure `vaadin-maven-plugin`:
```
<configuration>
<frontendHotdeploy>true</frontendHotdeploy>
</configuration>
```
- configure `jetty-maven-plugin`:
```
<configuration>
<systemProperties>
<vaadin.frontend.hotdeploy>true</vaadin.frontend.hotdeploy>
</systemProperties>
</configuration>
```
Read more [about Vaadin development mode](https://vaadin.com/docs/next/configuration/development-mode/#pre-compiled-front-end-bundle-for-faster-start-up).

Binary file not shown.

Binary file not shown.

View file

@ -3,14 +3,13 @@ package com.application.munera;
import com.application.munera.repositories.ExpenseRepository; import com.application.munera.repositories.ExpenseRepository;
import com.vaadin.flow.component.page.AppShellConfigurator; import com.vaadin.flow.component.page.AppShellConfigurator;
import com.vaadin.flow.theme.Theme; import com.vaadin.flow.theme.Theme;
import javax.sql.DataSource;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.sql.init.SqlDataSourceScriptDatabaseInitializer; import org.springframework.boot.autoconfigure.sql.init.SqlDataSourceScriptDatabaseInitializer;
import org.springframework.boot.autoconfigure.sql.init.SqlInitializationProperties; import org.springframework.boot.autoconfigure.sql.init.SqlInitializationProperties;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import javax.sql.DataSource;
/** /**
* The entry point of the Spring Boot application. * The entry point of the Spring Boot application.
* *

View file

@ -0,0 +1,72 @@
package com.application.munera;
import com.application.munera.views.login.LoginView;
import com.vaadin.flow.spring.security.VaadinWebSecurity;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.provisioning.UserDetailsManager;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
@EnableWebSecurity
@Configuration
public class SecurityConfiguration
extends VaadinWebSecurity {
@Override
protected void configure(HttpSecurity http) throws Exception {
// Delegating the responsibility of general configurations
// of http security to the super class. It's configuring
// the followings: Vaadin's CSRF protection by ignoring
// framework's internal requests, default request cache,
// ignoring public views annotated with @AnonymousAllowed,
// restricting access to other views/endpoints, and enabling
// NavigationAccessControl authorization.
// You can add any possible extra configurations of your own
// here (the following is just an example):
// http.rememberMe().alwaysRemember(false);
// Configure your static resources with public access before calling
// super.configure(HttpSecurity) as it adds final anyRequest matcher
http.authorizeHttpRequests(auth -> auth.requestMatchers(new AntPathRequestMatcher("/public/**"))
.permitAll());
super.configure(http);
// This is important to register your login view to the
// navigation access control mechanism:
setLoginView(http, LoginView.class);
}
@Override
public void configure(WebSecurity web) throws Exception {
// Customize your WebSecurity configuration.
super.configure(web);
}
/**
* Demo UserDetailsManager which only provides two hardcoded
* in memory users and their roles.
* NOTE: This shouldn't be used in real world applications.
*/
@Bean
public UserDetailsManager userDetailsService() {
UserDetails user =
User.withUsername("user")
.password("{noop}user")
.roles("USER")
.build();
UserDetails admin =
User.withUsername("admin")
.password("{noop}admin")
.roles("ADMIN")
.build();
return new InMemoryUserDetailsManager(user, admin);
}
}

View file

@ -5,8 +5,6 @@ import jakarta.validation.constraints.Size;
import lombok.Getter; import lombok.Getter;
import lombok.Setter; import lombok.Setter;
import java.util.Objects;
@Entity @Entity
@Getter @Getter
@Setter @Setter
@ -23,20 +21,4 @@ public class Category {
@Column(name = "Description") @Column(name = "Description")
private String description; private String description;
@Column(name = "userId", nullable = false)
private Long userId;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Category category = (Category) o;
return id != null && id.equals(category.id);
}
@Override
public int hashCode() {
return Objects.hash(id);
}
} }

View file

@ -0,0 +1,34 @@
package com.application.munera.data;
import jakarta.persistence.*;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;
import java.util.Set;
@Entity
@Getter
@Setter
@Table(name = "events")
public class Event {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Size(max = 100)
@Column(name = "Name", nullable = false)
private String name;
@Size(max = 100)
@Column(name = "Description")
private String description;
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(
name = "Event_participants",
joinColumns = @JoinColumn(name = "event_id"),
inverseJoinColumns = @JoinColumn(name = "participant_id"))
private Set<Person> participants;
}

View file

@ -1,22 +1,19 @@
package com.application.munera.data; package com.application.munera.data;
import com.application.munera.data.enums.ExpenseType;
import com.application.munera.data.enums.PeriodUnit;
import jakarta.persistence.*; import jakarta.persistence.*;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
import lombok.EqualsAndHashCode;
import lombok.Getter; import lombok.Getter;
import lombok.Setter; import lombok.Setter;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.Set;
@Entity @Entity
@Getter @Getter
@Setter @Setter
@EqualsAndHashCode
@Table(name = "expenses") @Table(name = "expenses")
public class Expense { public class Expense extends AbstractEntity {
@Id @Id
@GeneratedValue(strategy = GenerationType.IDENTITY) @GeneratedValue(strategy = GenerationType.IDENTITY)
@ -37,37 +34,39 @@ public class Expense {
@Column(name = "Description") @Column(name = "Description")
private String description; private String description;
@Column(name = "Periodic", nullable = false) @Column(name = "PeriodicExpense", nullable = false)
private Boolean isPeriodic; private Boolean isPeriodic;
@Enumerated(EnumType.ORDINAL) @Column(name = "PeriodUnit")
@Column(name = "Period")
private PeriodUnit periodUnit; private PeriodUnit periodUnit;
@Column(name = "PeriodInterval") @Column(name = "PeriodInterval")
private Integer periodInterval; private Integer periodInterval;
@ManyToOne @ManyToMany(fetch = FetchType.EAGER)
@JoinColumn(name = "CreditorId") @JoinTable(
private Person payer; name = "Creditor_expenses",
joinColumns = @JoinColumn(name = "expense_id"),
inverseJoinColumns = @JoinColumn(name = "people_id"))
private Set<Person> creditors;
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(
name = "Debtors_expenses",
joinColumns = @JoinColumn(name = "expense_id"),
inverseJoinColumns = @JoinColumn(name = "people_id"))
private Set<Person> debtors;
@ManyToOne @ManyToOne
@JoinColumn(name = "DebtorId") @JoinColumn(name = "EventId")
private Person beneficiary; private Event event;
@Column(name = "Date", nullable = false, columnDefinition = "DATE DEFAULT CURRENT_DATE") @Column(name = "Date", nullable = false, columnDefinition = "DATE DEFAULT CURRENT_DATE")
private LocalDate date; private LocalDate date;
@Column(name = "PaymentDate") /**
private LocalDate paymentDate; * the isResolved field starts as always false, cause at creation an expense cant be already be resolved
*/
@Column(name = "isPaid", nullable = false) @Column(name = "isResolved", nullable = false)
private Boolean isPaid = false; private Boolean isResolved = false;
@Enumerated(EnumType.STRING)
@Column(name = "type", nullable = false)
private ExpenseType expenseType;
@Column(name = "userId", nullable = false)
private Long userId;
} }

View file

@ -1,4 +1,4 @@
package com.application.munera.data.enums; package com.application.munera.data;
public enum PeriodUnit { public enum PeriodUnit {

View file

@ -14,7 +14,7 @@ import java.util.Set;
@Getter @Getter
@Setter @Setter
@Table(name = "people") @Table(name = "people")
public class Person { public class Person extends AbstractEntity {
@Id @Id
@GeneratedValue(strategy = GenerationType.IDENTITY) @GeneratedValue(strategy = GenerationType.IDENTITY)
@ -40,26 +40,24 @@ public class Person {
@Column(name = "credit") @Column(name = "credit")
private BigDecimal credit; private BigDecimal credit;
@OneToMany(mappedBy = "payer") @ManyToMany(mappedBy = "creditors")
private Set<Expense> expensesAsPayer; private Set<Expense> creditorExpenses;
@OneToMany(mappedBy = "beneficiary") @ManyToMany(mappedBy = "debtors")
private Set<Expense> expensesAsBeneficiary; private Set<Expense> debtorExpenses;
@Column(name = "Username", unique = true) @ManyToMany(mappedBy = "participants")
private String username; // This field will link to the User entity private Set<Event> events;
@Column(name = "UserId", nullable = false)
private Long userId; // Reference to the User entity
@Override @Override
public boolean equals(Object obj) { public boolean equals(Object obj) {
if (obj == this) { if (obj == this) {
return true; return true;
} }
if (!(obj instanceof Person other)) { if (!(obj instanceof Person)) {
return false; return false;
} }
Person other = (Person) obj;
return Objects.equals(firstName, other.firstName) && return Objects.equals(firstName, other.firstName) &&
Objects.equals(lastName, other.lastName) && Objects.equals(lastName, other.lastName) &&
Objects.equals(email, other.email); Objects.equals(email, other.email);

View file

@ -1,61 +0,0 @@
package com.application.munera.data;
import com.application.munera.data.enums.Role;
import jakarta.persistence.*;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
@Entity
@Data
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "Id", unique = true, nullable = false)
private Long id;
@Size(max = 100)
@Column(name = "FirstName", nullable = false)
private String firstName;
@Size(max = 100)
@Column(name = "LastName", nullable = false)
private String lastName;
@Size(max = 100)
@Column(name = "username",unique = true, nullable = false)
private String username;
@Size(max = 100)
@Column(name = "password", nullable = false)
private String password;
@Column(name = "roles", nullable = false)
private String roles;
@Email
@Size(max = 100)
@Column(name = "email")
private String email;
// Helper methods to handle roles as a list of enum values
public List<Role> getRoleList() {
if (roles == null || roles.isEmpty()) return new ArrayList<>(); // Return an empty list if roles are null or empty
return Arrays.stream(roles.split(","))
.map(Role::valueOf)
.toList();
}
public void setRoleList(List<Role> roleList) {
this.roles = roleList.stream()
.map(Role::name)
.collect(Collectors.joining(","));
}
}

View file

@ -1,23 +0,0 @@
package com.application.munera.data.enums;
import lombok.Getter;
@Getter
public enum BadgeMessage {
PAID_TO_ME("Paid to me", "badge success"),
PAID_BY_ME("Paid by me", "badge success"),
PAID("Paid", "badge success"),
OWED_TO_ME("Owed to me", "badge warning"),
OWED_BY_ME("Owed by me", "badge warning"),
NOT_PAID("Not paid", "badge warning"),
UNKNOWN("Unknown status", "badge error");
private final String text;
private final String theme;
BadgeMessage(String text, String theme) {
this.text = text;
this.theme = theme;
}
}

View file

@ -1,7 +0,0 @@
package com.application.munera.data.enums;
public enum ExpenseType {
CREDIT,
DEBIT,
NONE
}

View file

@ -1,20 +0,0 @@
package com.application.munera.data.enums;
import lombok.Getter;
@Getter
public enum Role {
ROLE_ADMIN("ROLE_ADMIN"),
ROLE_USER("ROLE_USER");
private final String roleName;
Role(String roleName) {
this.roleName = roleName;
}
@Override
public String toString() {
return roleName;
}
}

View file

@ -1,42 +0,0 @@
package com.application.munera.facades;
import com.application.munera.data.Expense;
import com.application.munera.data.Person;
import com.application.munera.services.ExpenseService;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.treegrid.TreeGrid;
import org.springframework.stereotype.Component;
import java.util.List;
@Component
public class ExpenseFacade {
public final ExpenseService expenseService;
public ExpenseFacade(ExpenseService expenseService) {
this.expenseService = expenseService;
}
/**
* Sets an expense as paid
* @param expense the expense to set as paid
* @param grid the grid reference to update
* @param userId the id of the user related to the expense
*/
public void setExpensePaid(Expense expense, TreeGrid<Object> grid, Long userId) {
expense.setIsPaid(true);
this.expenseService.update(expense, userId);
Notification.show("Expense " + expense.getName() + " set as paid" );
grid.select(null);
grid.getDataProvider().refreshAll();
}
/**
* Finds all expenses related to a person, both where the person is a payer and a beneficiary.
* @param person the person of the expenses
* @return the list of expenses found
*/
public List<Expense> findExpensesByPerson(final Person person) {
return this.expenseService.findExpensesByPerson(person);
}
}

View file

@ -1,151 +0,0 @@
package com.application.munera.facades;
import com.application.munera.data.Expense;
import com.application.munera.data.Person;
import com.application.munera.data.User;
import com.application.munera.services.ExpenseService;
import com.application.munera.services.PersonService;
import com.application.munera.services.UserService;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.notification.NotificationVariant;
import com.vaadin.flow.component.treegrid.TreeGrid;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
@Component
public class PersonFacade {
private final ExpenseService expenseService;
private final UserService userService;
private final PersonService personService;
public PersonFacade(ExpenseService expenseService, UserService userService, PersonService personService) {
this.expenseService = expenseService;
this.userService = userService;
this.personService = personService;
}
/**
* Fetches the {@code Person} entity associated with the currently logged-in user.
*
* @return the {@code Person} entity of the logged-in user, or {@code null} if not found
*/
public Person getLoggedInPerson() {
final var user = userService.getLoggedInUser();
return Objects.requireNonNull(personService.findByUsername(user.getUsername()));
}
/**
* Fetches all the people related to the user
* @param userId the id of the user related to the people
* @return the list of people found related to the id of the user
*/
public List<Person> findAllByUserId(Long userId) {
return this.personService.findAllByUserId(userId);
}
/**
* Finds a {@link Person} entity by its ID.
*
* @param id the ID of the person to find
* @return an {@link Optional} containing the person if found, or an empty {@link Optional} if not found
*/
public Optional<Person> findById(Long id) {
return this.personService.findById(id);
}
/**
* Updates the details of a {@link Person} entity and associates it with a specific user.
* This method will save the updated person details and associate it with the given user ID.
*
* @param person the {@link Person} entity to update
* @param userId the ID of the user associated with the person
*/
public void update(Person person, Long userId) {
this.personService.update(person, userId);
}
/**
* Deletes a {@link Person} entity by its ID.
* This method removes the person from the system based on the provided ID.
*
* @param id the ID of the person to delete
*/
public void delete(Long id) {
this.personService.delete(id);
}
/**
* Finds all {@code Person} entities associated with the logged-in user, excluding the logged-in user.
*
* @param user the logged-in user whose associated persons are to be retrieved
* @return a {@code List} of {@code Person} entities excluding the logged-in user
*/
public List<Person> findAllExcludeLoggedUser(final User user) {
return this.personService.findAllExcludeLoggedUser(user);
}
/**
* Calculates the net balance for a given {@code Person}.
*
* @param person the {@code Person} for whom the net balance is to be calculated
* @return the net balance as a {@code BigDecimal}
*/
public BigDecimal calculateNetBalance(final Person person) {
return this.personService.calculateNetBalance(person);
}
/**
* Marks all expenses as paid for the given {@code Person} where the person is the payer.
* Updates the user interface to reflect the changes and provides notifications for success or failure.
*
* @param person the {@code Person} whose expenses are to be marked as paid
* @param grid the {@code TreeGrid} component to refresh after updating expenses
* @param userId the ID of the user performing the update
*/
public void setDebtPaid(Person person, TreeGrid<Object> grid, Long userId) {
try {
List<Expense> expenses = expenseService.findExpensesWherePayer(person).stream().toList();
for (Expense expense : expenses) {
expense.setIsPaid(true);
expenseService.update(expense, userId);
}
Notification.show("All expenses marked as paid for " + person.getFirstName() + " " + person.getLastName());
grid.select(null);
grid.getDataProvider().refreshAll();
} catch (Exception e) {
Notification n = Notification.show("Error marking expenses as paid: " + e.getMessage());
n.setPosition(Notification.Position.MIDDLE);
n.addThemeVariants(NotificationVariant.LUMO_ERROR);
}
}
/**
* Marks all expenses as paid for the given {@code Person} where the person is the beneficiary.
* Updates the user interface to reflect the changes and provides notifications for success or failure.
*
* @param person the {@code Person} whose expenses are to be marked as paid
* @param grid the {@code TreeGrid} component to refresh after updating expenses
* @param userId the ID of the user performing the update
*/
public void setCreditPaid(Person person, TreeGrid<Object> grid, Long userId) {
try {
List<Expense> expenses = (List<Expense>) expenseService.findExpensesWhereBeneficiary(person);
for (Expense expense : expenses) {
expense.setIsPaid(true);
expenseService.update(expense, userId);
}
Notification.show("All expenses marked as paid for " + person.getFirstName() + " " + person.getLastName());
grid.select(null);
grid.getDataProvider().refreshAll();
} catch (Exception e) {
Notification n = Notification.show("Error marking expenses as paid: " + e.getMessage());
n.setPosition(Notification.Position.MIDDLE);
n.addThemeVariants(NotificationVariant.LUMO_ERROR);
}
}
}

View file

@ -1,68 +0,0 @@
package com.application.munera.initializers;
import com.application.munera.data.Category;
import com.application.munera.services.CategoryService;
import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class CategoryInitializer {
@Autowired
private CategoryService categoryService;
@PostConstruct
public void init() {
if (categoryService.count() == 0) {
// Create and save the Food category
Category foodCategory = new Category();
foodCategory.setName("Food");
foodCategory.setUserId(1L);
foodCategory.setDescription("All expenses related to food");
categoryService.save(foodCategory);
// Create and save the Travel category
Category travelCategory = new Category();
travelCategory.setName("Travel");
travelCategory.setUserId(1L);
travelCategory.setDescription("Expenses related to traveling, including transport and accommodation");
categoryService.save(travelCategory);
// Create and save the Electronics category
Category electronicsCategory = new Category();
electronicsCategory.setName("Electronics");
electronicsCategory.setUserId(1L);
electronicsCategory.setDescription("All expenses related to electronic devices and gadgets");
categoryService.save(electronicsCategory);
// Create and save the Events category
Category eventsCategory = new Category();
eventsCategory.setName("Events");
eventsCategory.setUserId(1L);
eventsCategory.setDescription("Expenses related to attending or organizing events");
categoryService.save(eventsCategory);
// Create and save the Clothing category
Category clothingCategory = new Category();
clothingCategory.setName("Clothing");
clothingCategory.setUserId(1L);
clothingCategory.setDescription("Expenses related to clothes and accessories");
categoryService.save(clothingCategory);
// Create and save the Bills category
Category billsCategory = new Category();
billsCategory.setName("Bills");
billsCategory.setUserId(1L);
billsCategory.setDescription("Recurring expenses like utilities, internet, and other bills");
categoryService.save(billsCategory);
// Create and save the Rent category
Category rentCategory = new Category();
rentCategory.setName("Rent");
rentCategory.setUserId(1L);
rentCategory.setDescription("Expenses related to rental payments for housing or office space");
categoryService.save(rentCategory);
}
}
}

View file

@ -1,32 +0,0 @@
package com.application.munera.initializers;
import com.application.munera.data.User;
import com.application.munera.security.AdminProperties;
import com.application.munera.services.UserService;
import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class UserInitializer {
@Autowired
private UserService userService;
@Autowired
private AdminProperties adminProperties;
@PostConstruct
public void init() {
if (userService.count() == 0) {
User adminUser = new User();
adminUser.setUsername(adminProperties.getUsername());
adminUser.setPassword(adminProperties.getPassword());
adminUser.setFirstName(adminProperties.getFirstName());
adminUser.setLastName(adminProperties.getLastName());
adminUser.setRoles(adminProperties.getRoles());
adminUser.setEmail(adminProperties.getEmail());
userService.saveOrUpdateUserAndConnectedPerson(adminUser);
}
}
}

View file

@ -4,9 +4,5 @@ import com.application.munera.data.Category;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import java.util.List;
public interface CategoryRepository extends JpaRepository<Category, Long>, JpaSpecificationExecutor<Category> { public interface CategoryRepository extends JpaRepository<Category, Long>, JpaSpecificationExecutor<Category> {
List<Category> findByUserId(Long userId);
} }

View file

@ -0,0 +1,8 @@
package com.application.munera.repositories;
import com.application.munera.data.Event;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
public interface EventRepository extends JpaRepository<Event, Long>, JpaSpecificationExecutor<Event> {
}

View file

@ -2,7 +2,6 @@ package com.application.munera.repositories;
import com.application.munera.data.Expense; import com.application.munera.data.Expense;
import com.application.munera.data.enums.ExpenseType;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.Query;
@ -12,57 +11,22 @@ import java.util.List;
import java.util.Set; import java.util.Set;
public interface ExpenseRepository extends JpaRepository<Expense, Long>, JpaSpecificationExecutor<Expense> { public interface ExpenseRepository extends JpaRepository<Expense, Long>, JpaSpecificationExecutor<Expense> {
@Query(value = "SELECT DISTINCT e FROM Expense e " +
"WHERE (e.payer.id = :userId AND e.beneficiary.id = :userId AND YEAR(e.date) = :year) " +
"OR (e.beneficiary.id = :userId AND YEAR(e.date) = :year) " +
"OR (e.payer.id = :userId AND e.isPaid = false AND YEAR(e.date) = :year)")
List<Expense> findExpensesForDashboard(@Param("userId") Long userId, @Param("year") int year);
@Query("SELECT DISTINCT YEAR(e.date) FROM Expense e WHERE e.userId = :userId ORDER BY YEAR(e.date)") @Query("SELECT e FROM Expense e JOIN e.creditors c WHERE c.id = :personId")
List<Integer> findExpenseYearsByUserId(@Param("userId") Long userId); Set<Expense> findCreditorsExpensesByPersonId(@Param("personId") Long personId);
// Find expenses where the payer is a specific person @Query("SELECT e FROM Expense e JOIN e.debtors d WHERE d.id = :personId")
@Query("SELECT e FROM Expense e WHERE e.payer.id = :personId") Set<Expense> findDebtorsExpensesByPersonId(@Param("personId") Long personId);
List<Expense> findExpensesByPayer(@Param("personId") Long personId);
// Find expenses where the beneficiary is a specific person
@Query("SELECT e FROM Expense e WHERE e.beneficiary.id = :personId")
List<Expense> findExpensesByBeneficiary(@Param("personId") Long personId);
// Find expenses where both payer and beneficiary are the same person
@Query("SELECT e FROM Expense e WHERE e.payer.id = :personId AND e.beneficiary.id = :personId")
List<Expense> findExpensesByPayerAndBeneficiary(@Param("personId") Long personId);
// Find expenses where the payer and beneficiary are the same person for a specific year
@Query("SELECT e FROM Expense e WHERE e.payer.id = :personId AND e.beneficiary.id = :personId AND YEAR(e.date) = :year")
List<Expense> findExpensesByPayerAndBeneficiaryAndYear(@Param("personId") Long personId, @Param("year") int year);
// Find expenses where the payer is a specific person for a specific year
@Query("SELECT e FROM Expense e WHERE e.payer.id = :personId AND YEAR(e.date) = :year")
List<Expense> findExpensesByPayerAndYear(@Param("personId") Long personId, @Param("year") int year);
// Find expenses where the beneficiary is a specific person for a specific year
@Query("SELECT e FROM Expense e WHERE e.beneficiary.id = :personId AND YEAR(e.date) = :year")
List<Expense> findExpensesByBeneficiaryAndYear(@Param("personId") Long personId, @Param("year") int year);
// Find all expenses for a given year
@Query("SELECT e FROM Expense e WHERE YEAR(e.date) = :year") @Query("SELECT e FROM Expense e WHERE YEAR(e.date) = :year")
List<Expense> findAllByYear(@Param("year") int year); List<Expense> findAllByYear(@Param("year") int year);
// Find unpaid expenses where the creditor is a specific person @Query("SELECT e FROM Expense e JOIN e.creditors c WHERE c.id = :personId AND e.isResolved = false")
@Query("SELECT e FROM Expense e WHERE e.payer.id = :personId AND e.isPaid = false") Set<Expense> findUnpaidCreditorsExpensesByPersonId(@Param("personId") Long personId);
Set<Expense> findUnpaidExpensesByPayer(@Param("personId") Long personId);
// Find unpaid expenses where the debtor is a specific person @Query("SELECT e FROM Expense e JOIN e.debtors d WHERE d.id = :personId AND e.isResolved = false")
@Query("SELECT e FROM Expense e WHERE e.beneficiary.id = :personId AND e.isPaid = false") Set<Expense> findUnpaidDebtorsExpensesByPersonId(@Param("personId") Long personId);
Set<Expense> findUnpaidExpensesByBeneficiary(@Param("personId") Long personId);
// Find expenses for a given year and filter by expense type and paid status boolean existsByIdAndIsResolvedTrue(Long id);
@Query("SELECT e FROM Expense e WHERE YEAR(e.date) = :year AND NOT (e.expenseType = :expenseType AND e.isPaid = true)")
List<Expense> findByYearAndFilterCreditPaid(@Param("year") int year, @Param("expenseType") ExpenseType expenseType);
// Check if an expense with the given ID exists and is paid List<Expense> findAllByOrderByDateDesc();}
boolean existsByIdAndIsPaidTrue(Long id);
// Find all expenses ordered by date descending
List<Expense> findByUserIdOrderByDateDesc(Long userId);
}

View file

@ -1,30 +1,8 @@
package com.application.munera.repositories; package com.application.munera.repositories;
import com.application.munera.data.Person; import com.application.munera.data.Person;
import com.application.munera.data.User;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;
public interface PersonRepository extends JpaRepository<Person, Long>, JpaSpecificationExecutor<Person> { public interface PersonRepository extends JpaRepository<Person, Long>, JpaSpecificationExecutor<Person> {
Person findByUsername(String username);
@Query("SELECT p FROM Person p WHERE p.username = :username")
Optional<Person> findOptionalByUsername(@Param("username") String username);
/**
* finds all the people that the logged user has created, minus the person that represents the logged user
* @param userId the logged user id, to get all people connected to id
* @param username the logged username, to filter out
* @return the list people found
*/
@Query("SELECT p FROM Person p WHERE p.userId = :userId AND (p.username IS NULL OR p.username <> :username)")
List<Person> findAllByUserIdExcludingPerson(@Param("userId") Long userId, @Param("username") String username);
List<Person> findByUserId(Long userId);
} }

View file

@ -1,16 +0,0 @@
package com.application.munera.repositories;
import com.application.munera.data.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import javax.annotation.Nonnull;
import java.util.Optional;
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
@Query("SELECT u FROM User u WHERE u.username = :username")
Optional<User> findByUsername(@Param("username") String username);}

View file

@ -1,19 +0,0 @@
package com.application.munera.security;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@Getter
@Setter
@Configuration
@ConfigurationProperties(prefix = "admin")
public class AdminProperties {
private String username;
private String password;
private String firstName;
private String lastName;
private String roles;
private String email;
}

View file

@ -1,64 +0,0 @@
package com.application.munera.security;
import com.application.munera.repositories.UserRepository;
import com.application.munera.views.login.LoginView;
import com.vaadin.flow.spring.security.VaadinWebSecurity;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.NoOpPasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.provisioning.UserDetailsManager;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
@EnableWebSecurity
@Configuration
public class SecurityConfiguration extends VaadinWebSecurity {
@Autowired
private UserRepository userRepository;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth.requestMatchers(new AntPathRequestMatcher("/public/**"))
.permitAll());
super.configure(http);
setLoginView(http, LoginView.class);
}
@Override
public void configure(WebSecurity web) throws Exception {
super.configure(web);
}
@Bean
public UserDetailsManager userDetailsManager() {
return new InMemoryUserDetailsManager() {
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
final var user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
// Assume roles are stored in the database with prefix, ex: "ROLE_ADMIN"
String[] roles = user.getRoles().split(",");
// Use authorities instead of roles to prevent automatic prefixing
return User.withUsername(user.getUsername())
.password(user.getPassword())
.authorities(roles) // Set roles directly as authorities
.build();
}
};
}
@Bean
public static NoOpPasswordEncoder passwordEncoder() {
return (NoOpPasswordEncoder) NoOpPasswordEncoder.getInstance();
}
}

View file

@ -1,16 +0,0 @@
package com.application.munera.security;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
public class SecurityUtils {
public static UserDetails getLoggedInUserDetails() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && authentication.getPrincipal() instanceof UserDetails) {
return (UserDetails) authentication.getPrincipal();
}
return null;
}
}

View file

@ -1,39 +0,0 @@
package com.application.munera.services;
import com.application.munera.data.Expense;
import com.vaadin.flow.server.StreamResource;
import org.apache.commons.csv.CSVFormat;
import org.apache.commons.csv.CSVPrinter;
import org.springframework.stereotype.Service;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.OutputStreamWriter;
import java.nio.charset.StandardCharsets;
import java.util.List;
@Service
public class CSVService {
public StreamResource createCSVResource(List<Expense> expenses) {
return new StreamResource("expenses.csv", () -> {
ByteArrayOutputStream stream = new ByteArrayOutputStream();
try (OutputStreamWriter writer = new OutputStreamWriter(stream, StandardCharsets.UTF_8);
CSVPrinter csvPrinter = new CSVPrinter(writer, CSVFormat.DEFAULT.withHeader("Name", "Cost", "Category", "Date", "Payment date"))) {
for (Expense expense : expenses) {
csvPrinter.printRecord(
expense.getName(),
expense.getCost(),
expense.getCategory() != null ? expense.getCategory().getName() : "",
expense.getDate(),
expense.getPaymentDate() != null ? expense.getPaymentDate() : "Unpaid"
);
}
} catch (Exception e) {
e.printStackTrace();
}
return new ByteArrayInputStream(stream.toByteArray());
});
}
}

View file

@ -18,72 +18,23 @@ public class CategoryService {
this.categoryRepository = categoryRepository; this.categoryRepository = categoryRepository;
} }
/**
* Finds a category by its ID.
*
* @param id the ID of the category to find
* @return an {@code Optional} containing the found category, or {@code Optional.empty()} if no category with the given ID exists
*/
public Optional<Category> findById(Long id) { public Optional<Category> findById(Long id) {
return categoryRepository.findById(id); return categoryRepository.findById(id);
} }
/** public List<Category> findAll() {
* Finds all categories associated with a specific user ID. return categoryRepository.findAll();
*
* @param userId the ID of the user whose categories are to be retrieved
* @return a {@code List} of categories associated with the given user ID
*/
public List<Category> findAllByUserId(Long userId) {
return categoryRepository.findByUserId(userId);
} }
/** public void update(Category category) {
* Updates the provided category and associates it with a specific user ID.
*
* @param category the category to update
* @param userId the ID of the user to associate with the category
*/
public void update(Category category, Long userId) {
category.setUserId(userId);
categoryRepository.save(category); categoryRepository.save(category);
} }
/**
* Deletes the given category.
*
* @param category the category to delete
*/
public void delete(Category category) { public void delete(Category category) {
categoryRepository.delete(category); categoryRepository.delete(category);
} }
/**
* Retrieves a paginated list of categories.
*
* @param pageable the pagination information
* @return a {@code Page} containing the categories for the requested page
*/
public Page<Category> list(Pageable pageable){ public Page<Category> list(Pageable pageable){
return categoryRepository.findAll(pageable); return categoryRepository.findAll(pageable);
} }
/**
* Counts the total number of categories.
*
* @return the total number of categories
*/
public Long count() {
return this.categoryRepository.count();
}
/**
* Saves the given category to the repository.
*
* @param category the category to save
* @return the saved category
*/
public Category save(Category category) {
return this.categoryRepository.save(category);
}
} }

View file

@ -0,0 +1,40 @@
package com.application.munera.services;
import com.application.munera.data.Event;
import com.application.munera.repositories.EventRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
@Service
public class EventService {
private final EventRepository eventRepository;
public EventService(final EventRepository eventRepository){
this.eventRepository = eventRepository;
}
public Optional<Event> findById(Long id) {
return eventRepository.findById(id);
}
public List<Event> findAll() {
return eventRepository.findAll();
}
public void update(Event event) {
eventRepository.save(event);
}
public void delete(Event event) {
eventRepository.delete(event);
}
public Page<Event> list(Pageable pageable){
return eventRepository.findAll(pageable);
}
}

View file

@ -2,222 +2,84 @@ package com.application.munera.services;
import com.application.munera.data.Expense; import com.application.munera.data.Expense;
import com.application.munera.data.Person; import com.application.munera.data.Person;
import com.application.munera.data.enums.ExpenseType;
import com.application.munera.repositories.ExpenseRepository; import com.application.munera.repositories.ExpenseRepository;
import com.application.munera.repositories.PersonRepository;
import com.application.munera.repositories.UserRepository;
import com.application.munera.security.SecurityUtils;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification; import org.springframework.data.jpa.domain.Specification;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import javax.annotation.Nonnull; import java.util.Collection;
import java.time.LocalDate; import java.util.List;
import java.time.Year; import java.util.Optional;
import java.util.*;
import java.util.stream.Stream; import java.util.stream.Stream;
@Service @Service
public class ExpenseService { public class ExpenseService {
private final ExpenseRepository expenseRepository; private final ExpenseRepository repository;
private final UserRepository userRepository;
private final PersonRepository personRepository;
public ExpenseService(ExpenseRepository expenseRepository, UserRepository userRepository, PersonRepository personRepository) { public ExpenseService(ExpenseRepository repository) {
this.expenseRepository = expenseRepository; this.repository = repository;
this.userRepository = userRepository;
this.personRepository = personRepository;
} }
/**
* Retrieves an expense by its ID.
* @param id the ID of the expense
* @return an Optional containing the expense if found, otherwise empty
*/
public Optional<Expense> get(Long id) { public Optional<Expense> get(Long id) {
return expenseRepository.findById(id); return repository.findById(id);
} }
/** public Collection<Expense> findDebtByUser(final Person person) {
* Finds all expenses where the specified person is the beneficiary. return repository.findDebtorsExpensesByPersonId(person.getId());
* @param person the user of the expenses
* @return the collection of expenses found
*/
public Collection<Expense> findExpensesWhereBeneficiary(final Person person) {
return expenseRepository.findExpensesByBeneficiary(person.getId());
} }
/** public Collection<Expense> findCreditByUser(final Person person) {
* Finds all expenses where the specified person is the payer. return repository.findCreditorsExpensesByPersonId(person.getId());
* @param person the user of the expenses
* @return the collection of expenses found
*/
public Collection<Expense> findExpensesWherePayer(final Person person) {
return expenseRepository.findExpensesByPayer(person.getId());
} }
/** public Collection<Expense> findUnpaidDebtByUser(final Person person) {
* Finds all expenses where the specified person is the beneficiary and the expense is unpaid. return repository.findUnpaidDebtorsExpensesByPersonId(person.getId());
* @param person the user of the expenses
* @return the collection of unpaid expenses found
*/
public Collection<Expense> findUnpaidExpensesWhereBeneficiary(final Person person) {
return expenseRepository.findUnpaidExpensesByBeneficiary(person.getId());
} }
/** public Collection<Expense> findUnpaidCreditByUser(final Person person) {
* Finds all expenses where the specified person is the payer and the expense is unpaid. return repository.findUnpaidCreditorsExpensesByPersonId(person.getId());
* @param person the user of the expenses
* @return the collection of unpaid expenses found
*/
public Collection<Expense> findUnpaidExpensesWherePayer(final Person person) {
return expenseRepository.findUnpaidExpensesByPayer(person.getId());
} }
/** public List<Expense> findExpenseByUser(final Person person) {
* Finds all expenses related to a person, both where the person is a payer and a beneficiary. final var credits = this.findCreditByUser(person);
* @param person the person of the expenses final var debits = this.findDebtByUser(person);
* @return the list of expenses found return Stream.concat(credits.stream(), debits.stream()).toList();
*/
public List<Expense> findExpensesByPerson(final Person person) {
// Retrieve expenses where the person is the payer
final var payerExpenses = this.findExpensesWherePayer(person);
// Retrieve expenses where the person is the beneficiary
final var beneficiaryExpenses = this.findExpensesWhereBeneficiary(person);
// Combine both sets of expenses into a single list without duplicates
return Stream.concat(payerExpenses.stream(), beneficiaryExpenses.stream())
.distinct()
.toList();
}
/**
* Retrieves all expenses.
* @return the list of all expenses
*/
public List<Expense> findAll() {
return expenseRepository.findAll();
} }
/** public List<Expense> findAll() {return repository.findAll();}
* Finds all expenses for a given year.
* @param year the year for which to find expenses public void update(Expense entity) {
* @return the list of expenses found repository.save(entity);
*/
public List<Expense> findAllByYear(final int year) {
return this.expenseRepository.findAllByYear(year);
} }
/**
* Fetches all expenses ordered by date in descending order.
* @return the list of expenses found
*/
public List<Expense> findAllOrderByDateDescending(Long userId) {
return this.expenseRepository.findByUserIdOrderByDateDesc(userId);
}
/**
* Checks if an expense has been paid.
* @param expense the expense to check
* @return true if the expense has been paid, false otherwise
*/
public boolean isExpensePaid(final Expense expense) {
return this.expenseRepository.existsByIdAndIsPaidTrue(expense.getId());
}
/**
* Updates an existing expense.
* @param entity the expense to update
*/
public void update(Expense entity, Long userId) {
entity.setUserId(userId);
if (Boolean.TRUE.equals(entity.getIsPaid())) entity.setPaymentDate(LocalDate.now());
else entity.setPaymentDate(null);
this.setExpenseType(entity);
expenseRepository.save(entity);
}
/**
* Deletes an expense given its ID.
* @param id the ID of the expense to delete
*/
public void delete(Long id) { public void delete(Long id) {
expenseRepository.deleteById(id); repository.deleteById(id);
} }
/**
* Lists expenses in a paginated format.
* @param pageable the pagination information
* @return a page of expenses
*/
public Page<Expense> list(Pageable pageable) { public Page<Expense> list(Pageable pageable) {
return expenseRepository.findAll(pageable); return repository.findAll(pageable);
} }
/**
* Lists expenses in a paginated format with filtering options.
* @param pageable the pagination information
* @param filter the filter specification
* @return a page of expenses matching the filter
*/
public Page<Expense> list(Pageable pageable, Specification<Expense> filter) { public Page<Expense> list(Pageable pageable, Specification<Expense> filter) {
return expenseRepository.findAll(filter, pageable); return repository.findAll(filter, pageable);
} }
/**
* Counts the total number of expenses.
* @return the count of expenses
*/
public int count() { public int count() {
return (int) expenseRepository.count(); return (int) repository.count();
} }
/** public List<Expense> findAllByYear(final int year ) {
* Fetches the yearly net expenses of the user return this.repository.findAllByYear(year);
* @param loggedInPerson the logged-in user
* @param year the year from which we want the expenses
* @return the list of expenses of that user in that year
*/
public List<Expense> fetchExpensesForDashboard(Person loggedInPerson, Year year) {
return this.expenseRepository.findExpensesForDashboard(loggedInPerson.getUserId(), year.getValue());
} }
/** public boolean isExpenseResolved(final Expense expense) {
* Gets the list of years in which at least one expense has been made return this.repository.existsByIdAndIsResolvedTrue(expense.getId());
* @param userId the user from which to look into the expenses
* @return the list of years found
*/
public List<Integer> getAvailableExpenseYearsForUser(Long userId) {
return expenseRepository.findExpenseYearsByUserId(userId);
} }
// ================================ public List<Expense> findAllOrderByDateDescending() {
// Private methods return this.repository.findAllByOrderByDateDesc();
// ================================
/**
* Sets the expense type depending on the presence or absence of a payer and beneficiary.
* @param expense the expense to set the type of
*/
private void setExpenseType(final @Nonnull Expense expense) {
// Get the currently logged-in user
final var userDetails = SecurityUtils.getLoggedInUserDetails();
if (userDetails == null) throw new IllegalStateException("No logged-in user found");
// Fetch the logged-in user
final var loggedInUser = userRepository.findByUsername(userDetails.getUsername())
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
Person loggedInPerson = this.personRepository.findByUsername(loggedInUser.getUsername());
if (loggedInPerson == null) throw new IllegalStateException("No associated Person entity found for logged-in user");
// Check if the payer and beneficiary are present
Person payer = expense.getPayer();
Person beneficiary = expense.getBeneficiary();
// Determine the expense type
if (payer.equals(loggedInPerson) && !beneficiary.equals(loggedInPerson)) expense.setExpenseType(ExpenseType.CREDIT); // Logged-in user is the payer, and the beneficiary is someone else
else if (!payer.equals(loggedInPerson) && beneficiary.equals(loggedInPerson)) expense.setExpenseType(ExpenseType.DEBIT); // Logged-in user is the beneficiary, and the payer is someone else
else if (payer.equals(loggedInPerson)) expense.setExpenseType(ExpenseType.NONE); // Both payer and beneficiary are the logged-in user
} }
} }

View file

@ -2,8 +2,6 @@ package com.application.munera.services;
import com.application.munera.data.Expense; import com.application.munera.data.Expense;
import com.application.munera.data.Person; import com.application.munera.data.Person;
import com.application.munera.data.User;
import com.application.munera.repositories.ExpenseRepository;
import com.application.munera.repositories.PersonRepository; import com.application.munera.repositories.PersonRepository;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
@ -11,138 +9,59 @@ import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.List; import java.util.Collection;
import java.util.Optional; import java.util.Optional;
@Service @Service
public class PersonService { public class PersonService {
private final PersonRepository personRepository; private final PersonRepository personRepository;
private final ExpenseRepository expenseRepository; private final ExpenseService expenseService;
public PersonService(PersonRepository personRepository, ExpenseRepository expenseRepository) { public PersonService(PersonRepository personRepository, ExpenseService expenseService) {
this.personRepository = personRepository; this.personRepository = personRepository;
this.expenseRepository = expenseRepository; this.expenseService = expenseService;
} }
/** public Optional<Person> get(Long id) {
* Finds a person by ID.
* @param id the ID of the person
* @return an optional containing the person if found, otherwise empty
*/
public Optional<Person> findById(Long id) {
return personRepository.findById(id); return personRepository.findById(id);
} }
/** public Collection<Person> findAll() {
* Finds all people. return this.personRepository.findAll();
* @return a collection of all persons
*/
public List<Person> findAllByUserId(Long userId) {
return this.personRepository.findByUserId(userId);
} }
public Person findByUsername(String username) { public void update(Person person) {
return this.personRepository.findByUsername(username);
}
/**
* Finds all people excluding the users'ones.
* @return a collection of all persons
*/
public List<Person> findAllExcludeLoggedUser(User user) {
final var userId = user.getId();
final var username = user.getUsername();
return this.personRepository.findAllByUserIdExcludingPerson(userId, username);
}
/**
* Lists all persons with pagination.
* @param pageable the pagination information
* @return a page of persons
*/
public Page<Person> list(Pageable pageable) {
return personRepository.findAll(pageable);
}
/**
* Lists all persons with pagination and filtering.
* @param pageable the pagination information
* @param filter the specification filter
* @return a page of persons matching the filter
*/
public Page<Person> list(Pageable pageable, Specification<Person> filter) {
return this.personRepository.findAll(filter, pageable);
}
/**
* Counts the total number of persons.
* @return the total count of persons
*/
public int count() {
return (int) this.personRepository.count();
}
/**
* Updates a person in the repository.
* @param person the person to update
*/
public void update(Person person, Long userId) {
person.setUserId(userId);
this.personRepository.save(person); this.personRepository.save(person);
} }
/**
* Deletes a person by ID.
* @param id the ID of the person to delete
*/
public void delete(Long id) { public void delete(Long id) {
this.personRepository.deleteById(id); this.personRepository.deleteById(id);
} }
/** public Page<Person> list(Pageable pageable){
* Calculates the total debt of a person. return personRepository.findAll(pageable);
* @param person the person whose debt is to be calculated }
* @return the total debt amount
*/ public Page<Person> list(Pageable pageable, Specification<Person> filter) {
public BigDecimal calculateDebt(final Person person) { return this.personRepository.findAll(filter, pageable);
return this.expenseRepository.findExpensesByPayer(person.getId()).stream() }
.filter(expense -> !expense.getBeneficiary().equals(person) && Boolean.FALSE.equals(expense.getIsPaid()))
.map(Expense::getCost) public int count() {
.reduce(BigDecimal.ZERO, BigDecimal::add); return (int) this.personRepository.count();
}
public BigDecimal calculateDebt(final Person person){
return this.expenseService.findDebtByUser(person).stream().map(Expense::getCost).reduce(BigDecimal.ZERO, BigDecimal::add);
} }
/**
* Calculates the total credit of a person.
* @param person the person whose credit is to be calculated
* @return the total credit amount
*/
public BigDecimal calculateCredit(final Person person) { public BigDecimal calculateCredit(final Person person) {
return this.expenseRepository.findExpensesByBeneficiary(person.getId()).stream() return this.expenseService.findCreditByUser(person).stream().map(Expense::getCost).reduce(BigDecimal.ZERO, BigDecimal::add);
.filter(expense -> !expense.getPayer().equals(person) && Boolean.FALSE.equals(expense.getIsPaid()))
.map(Expense::getCost)
.reduce(BigDecimal.ZERO, BigDecimal::add);
} }
/**
* Calculates the net balance of a person.
* The net balance is the difference between the total amount the person is owed
* (expenses where they are the payer) and the total amount the person owes
* (expenses where they are the beneficiary).
*
* A positive net balance means the person is owed money.
* A negative net balance means the person owes money.
*
* @param person the person whose net balance is to be calculated
* @return the net balance amount
*/
public BigDecimal calculateNetBalance(final Person person) { public BigDecimal calculateNetBalance(final Person person) {
// Calculate total debt (what others owe to the person) final var credit = this.expenseService.findUnpaidCreditByUser(person).stream().map(Expense::getCost).reduce(BigDecimal.ZERO, BigDecimal::add);
final BigDecimal debt = this.calculateDebt(person); final var debit = this.expenseService.findUnpaidDebtByUser(person).stream().map(Expense::getCost).reduce(BigDecimal.ZERO, BigDecimal::add);
// Calculate total credit (what the person owes to others) return credit.subtract(debit);
final BigDecimal credit = this.calculateCredit(person);
// Net balance calculation: debt (owed to the person) - credit (person owes)
return debt.subtract(credit);
} }
} }

View file

@ -1,151 +0,0 @@
package com.application.munera.services;
import com.application.munera.data.Person;
import com.application.munera.data.User;
import com.application.munera.repositories.PersonRepository;
import com.application.munera.repositories.UserRepository;
import jakarta.transaction.Transactional;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
import static com.application.munera.security.SecurityUtils.getLoggedInUserDetails;
@Service
public class UserService {
private final UserRepository userRepository;
private final PersonRepository personRepository;
public UserService(UserRepository userRepository, PersonRepository personRepository) {
this.userRepository = userRepository;
this.personRepository = personRepository;
}
/**
* Retrieves all {@link User} entities.
*
* @return a list of all users
*/
public List<User> findAll() {
return this.userRepository.findAll();
}
/**
* Finds a {@link User} entity by its ID.
*
* @param id the ID of the user to find
* @return an {@link Optional} containing the user if found, or an empty {@link Optional} if not found
*/
public Optional<User> findById(final Long id) {
return this.userRepository.findById(id);
}
/**
* Finds a {@link User} entity by its username.
*
* @param username the username of the user to find
* @return an {@link Optional} containing the user if found, or an empty {@link Optional} if not found
*/
public Optional<User> findByUsername(String username) {
return this.userRepository.findByUsername(username);
}
/**
* Fetches the {@link User} entity of the currently logged-in user.
*
* @return the logged-in {@link User} entity
* @throws IllegalStateException if the user is not logged in or not found
*/
public User getLoggedInUser() {
final var userDetails = getLoggedInUserDetails();
if (userDetails == null) throw new IllegalStateException("User is not logged in.");
final var username = userDetails.getUsername();
return userRepository.findByUsername(username)
.orElseThrow(() -> new IllegalStateException("User not found: " + username));
}
/**
* Saves or updates a {@link User} entity and its associated {@link Person} entity.
* If the user already exists, it updates the user and the associated person.
* If the user does not exist, it creates a new user and a new person entity.
*
* @param user the {@link User} entity to save or update
*/
@Transactional
public void saveOrUpdateUserAndConnectedPerson(User user) {
// Check if the user already exists in the database
final var existingUserOptional = userRepository.findByUsername(user.getUsername());
User userToSave = getUser(user, existingUserOptional);
// Save the user entity
userRepository.save(userToSave);
// Check if the associated person exists for the user
final var existingPersonOptional = personRepository.findOptionalByUsername(userToSave.getUsername());
if (existingPersonOptional.isPresent()) {
// If person exists, update the person entity
Person personToUpdate = existingPersonOptional.get();
personToUpdate.setFirstName(userToSave.getFirstName());
personToUpdate.setLastName(userToSave.getLastName());
personToUpdate.setUsername(user.getUsername());
personToUpdate.setEmail(userToSave.getEmail());
personRepository.save(personToUpdate);
} else {
// If no person is associated with the user, create a new Person entity and link it to the User
Person newPerson = new Person();
newPerson.setFirstName(userToSave.getFirstName());
newPerson.setLastName(userToSave.getLastName());
newPerson.setEmail(userToSave.getEmail());
newPerson.setUsername(userToSave.getUsername());
newPerson.setUserId(userToSave.getId());
personRepository.save(newPerson);
}
}
/**
* Counts the total number of {@link User} entities.
*
* @return the total number of users
*/
public Long count() {
return this.userRepository.count();
}
/**
* Deletes the specified {@link User} entity and its associated {@link Person} entity.
*
* @param user the {@link User} entity to delete
*/
public void delete(final User user) {
this.userRepository.delete(user);
final var person = this.personRepository.findByUsername(user.getUsername());
this.personRepository.delete(person);
}
/**
* Determines whether a user exists and creates or updates the {@link User} entity accordingly.
*
* @param user the {@link User} entity to save or update
* @param existingUserOptional an {@link Optional} containing an existing user if found
* @return the user to be saved
*/
private User getUser(User user, Optional<User> existingUserOptional) {
User userToSave;
if (existingUserOptional.isPresent()) {
// If user exists, update the user entity
userToSave = existingUserOptional.get();
userToSave.setFirstName(user.getFirstName());
userToSave.setLastName(user.getLastName());
userToSave.setEmail(user.getEmail());
userToSave.setUsername(user.getUsername());
userToSave.setPassword(user.getPassword());
userToSave.setRoles(user.getRoles());
} else {
userToSave = user; // If user does not exist, save the new user entity
}
return userToSave;
}
}

View file

@ -1,125 +0,0 @@
package com.application.munera.services;
import com.application.munera.data.enums.BadgeMessage;
import com.application.munera.data.Category;
import com.application.munera.data.Expense;
import com.application.munera.data.enums.ExpenseType;
import com.vaadin.flow.component.combobox.MultiSelectComboBox;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.textfield.TextField;
import org.springframework.stereotype.Service;
import org.vaadin.klaudeta.PaginatedGrid;
import java.math.BigDecimal;
import java.util.List;
import java.util.Objects;
@Service
public class ViewsService {
private final ExpenseService expenseService;
public ViewsService(ExpenseService expenseService) {
this.expenseService = expenseService;
}
/**
* Creates a badge (Vaadin {@link Span}) based on the type of the expense and its payment status.
*
* @param expense the expense for which the badge is being created.
* @return a {@link Span} object representing the expense status badge.
*/
public Span createExpenseBadge(final Expense expense) {
final var isExpensePaid = Boolean.TRUE.equals(this.expenseService.isExpensePaid(expense));
final var badgeMessage = determineBadgeMessage(expense.getExpenseType(), isExpensePaid);
final var badge = new Span();
badge.setText(badgeMessage.getText());
badge.getElement().getThemeList().add(badgeMessage.getTheme());
return badge;
}
/**
* Creates a badge (Vaadin {@link Span}) that reflects a person's financial balance status
* based on the net balance provided.
*
* @param netBalance the net balance of the person.
* @return a {@link Span} object representing the person's financial status badge.
*/
public Span createPersonBadge(BigDecimal netBalance) {
Span badge = new Span();
if (netBalance.compareTo(BigDecimal.ZERO) < 0) {
badge.setText("Credit");
badge.getElement().getThemeList().add("badge success");
} else if (netBalance.compareTo(BigDecimal.ZERO) > 0) {
badge.setText("Debit");
badge.getElement().getThemeList().add("badge error");
} else {
badge.setText("Clear");
badge.getElement().getThemeList().add("badge contrast");
}
return badge;
}
/**
* Applies a name-based filter on the expenses displayed in the provided grid.
* If the filter value is empty, all expenses are displayed; otherwise, expenses
* whose names match the filter are shown.
*
* @param nameFilter the {@link TextField} containing the name filter value.
* @param userId the ID of the user whose expenses are being filtered.
* @param grid the {@link PaginatedGrid} that displays the expenses.
*/
public void applyNameFilter(TextField nameFilter, Long userId, PaginatedGrid<Expense, Objects> grid) {
final var filterValue = nameFilter.getValue().trim();
List<Expense> filteredExpenses;
if (filterValue.isEmpty()) filteredExpenses = expenseService.findAllOrderByDateDescending(userId); // If the filter is empty, return all expenses
else {
// Apply the filter (e.g., by name)
filteredExpenses = expenseService.findAllOrderByDateDescending(userId)
.stream()
.filter(expense -> expense.getName().toLowerCase().contains(filterValue.toLowerCase())).toList();
}
grid.setItems(filteredExpenses);
}
/**
* Applies a category-based filter on the expenses displayed in the provided grid.
* If no categories are selected, all expenses are shown; otherwise, only expenses
* that match the selected categories are displayed.
*
* @param categoryFilter the {@link MultiSelectComboBox} containing the selected categories.
* @param userId the ID of the user whose expenses are being filtered.
* @param grid the {@link PaginatedGrid} that displays the expenses.
*/
public void applyCategoryFilter(MultiSelectComboBox<Category> categoryFilter, Long userId, PaginatedGrid<Expense, Objects> grid) {
final var selectedCategories = categoryFilter.getValue();
List<Expense> filteredExpenses;
if (selectedCategories.isEmpty()) filteredExpenses = expenseService.findAllOrderByDateDescending(userId); // If no categories are selected, return all expenses
else {
// Apply the filter by selected categories
filteredExpenses = expenseService.findAllOrderByDateDescending(userId)
.stream()
.filter(expense1 -> selectedCategories.contains(expense1.getCategory()))
.toList();
}
grid.setItems(filteredExpenses);
}
/**
* Determines the message and theme for a badge based on the expense type and its payment status.
*
* @param type the type of the expense (CREDIT, DEBIT, or NONE).
* @param isPaid boolean indicating whether the expense has been paid.
* @return a {@link BadgeMessage} object containing the text and theme for the badge.
*/
private BadgeMessage determineBadgeMessage(ExpenseType type, boolean isPaid) {
return switch (type) {
case CREDIT -> isPaid ? BadgeMessage.PAID_TO_ME : BadgeMessage.OWED_TO_ME;
case DEBIT -> isPaid ? BadgeMessage.PAID_BY_ME : BadgeMessage.OWED_BY_ME;
case NONE -> isPaid ? BadgeMessage.PAID : BadgeMessage.NOT_PAID;
};
}
}

View file

@ -1,28 +1,25 @@
package com.application.munera.views; package com.application.munera.views;
import com.application.munera.services.CSVService;
import com.application.munera.services.ExpenseService;
import com.application.munera.services.UserService;
import com.application.munera.views.categories.CategoriesView; import com.application.munera.views.categories.CategoriesView;
import com.application.munera.views.dashboard.DashboardView; import com.application.munera.views.dashboard.DashboardView;
import com.application.munera.views.expenses.ExpensesView; import com.application.munera.views.events.EventsView;
import com.application.munera.views.expenses.*;
import com.application.munera.views.people.PeopleView; import com.application.munera.views.people.PeopleView;
import com.application.munera.views.settings.SettingsView;
import com.application.munera.views.users.UsersView;
import com.vaadin.flow.component.applayout.AppLayout; import com.vaadin.flow.component.applayout.AppLayout;
import com.vaadin.flow.component.applayout.DrawerToggle; import com.vaadin.flow.component.applayout.DrawerToggle;
import com.vaadin.flow.component.button.Button; import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.html.*; import com.vaadin.flow.component.html.Footer;
import com.vaadin.flow.component.html.H1;
import com.vaadin.flow.component.html.Header;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.orderedlayout.FlexComponent; import com.vaadin.flow.component.orderedlayout.FlexComponent;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout; import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.Scroller; import com.vaadin.flow.component.orderedlayout.Scroller;
import com.vaadin.flow.component.sidenav.SideNav; import com.vaadin.flow.component.sidenav.SideNav;
import com.vaadin.flow.component.sidenav.SideNavItem; import com.vaadin.flow.component.sidenav.SideNavItem;
import com.vaadin.flow.router.PageTitle; import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.server.StreamResource;
import com.vaadin.flow.spring.security.AuthenticationContext; import com.vaadin.flow.spring.security.AuthenticationContext;
import com.vaadin.flow.theme.lumo.LumoUtility; import com.vaadin.flow.theme.lumo.LumoUtility;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.vaadin.lineawesome.LineAwesomeIcon; import org.vaadin.lineawesome.LineAwesomeIcon;
/** /**
@ -31,17 +28,10 @@ import org.vaadin.lineawesome.LineAwesomeIcon;
public class MainLayout extends AppLayout { public class MainLayout extends AppLayout {
private H1 viewTitle; private H1 viewTitle;
private Button exportToCSVButton;
private final transient AuthenticationContext authContext; private final transient AuthenticationContext authContext;
private final CSVService csvService;
private final ExpenseService expenseService;
private final UserService userService;
public MainLayout(AuthenticationContext authContext, CSVService csvService, ExpenseService expenseService, UserService userService) { public MainLayout(AuthenticationContext authContext) {
this.authContext = authContext; this.authContext = authContext;
this.csvService = csvService;
this.expenseService = expenseService;
this.userService = userService;
setPrimarySection(Section.DRAWER); setPrimarySection(Section.DRAWER);
addDrawerContent(); addDrawerContent();
addHeaderContent(); addHeaderContent();
@ -54,52 +44,21 @@ public class MainLayout extends AppLayout {
viewTitle = new H1(); viewTitle = new H1();
viewTitle.addClassNames(LumoUtility.FontSize.LARGE, LumoUtility.Margin.NONE); viewTitle.addClassNames(LumoUtility.FontSize.LARGE, LumoUtility.Margin.NONE);
// Retrieve the authenticated user's name // Creating the logout button
String username = authContext.getPrincipalName().orElse("Guest");
// Create the user icon
final var userIcon = LineAwesomeIcon.USER.create();
userIcon.addClassNames(LumoUtility.FontSize.LARGE); // Make the icon size larger
// Create a Span to display "Hi, username"
Span greeting = new Span("Hi, " + username);
greeting.addClassNames(LumoUtility.FontSize.LARGE, LumoUtility.FontWeight.BOLD); // Make text larger and bold
// Combine the user icon and greeting in a horizontal layout
HorizontalLayout userInfoLayout = new HorizontalLayout(userIcon, greeting);
userInfoLayout.setAlignItems(FlexComponent.Alignment.CENTER); // Center vertically
// Create the logout button
Button logout = new Button("Logout", click -> this.authContext.logout()); Button logout = new Button("Logout", click -> this.authContext.logout());
logout.getStyle().set("padding", "10px"); // Add padding to the logout button
// Create the Export to CSV button // Adding some padding to the logout button
exportToCSVButton = new Button("Export Expenses to CSV"); logout.getStyle().set("padding", "10px");
exportToCSVButton.addClickListener(event -> {
// Call the CSV service to create the CSV resource
StreamResource resource = this.csvService.createCSVResource(this.expenseService.findAll());
resource.setCacheTime(0); // Disable caching to ensure fresh download each time
// Create a temporary link to trigger the download // Creating the header and adding the logout button to the far left
Anchor downloadLink = new Anchor(resource, ""); HorizontalLayout header = new HorizontalLayout(logout);
downloadLink.getElement().setAttribute("download", true);
downloadLink.getElement().setAttribute("hidden", true); // Make the link invisible
getElement().appendChild(downloadLink.getElement());
// Programmatically click the link to start the download
downloadLink.getElement().callJsFunction("click");
});
exportToCSVButton.getStyle().set("margin", "0 15px"); // Set margin only for the left and right
exportToCSVButton.setVisible(false); // Initially hidden
// Create the header layout and add all elements
HorizontalLayout header = new HorizontalLayout(userInfoLayout, logout);
header.setWidthFull(); // Make the header take the full width header.setWidthFull(); // Make the header take the full width
header.setDefaultVerticalComponentAlignment(FlexComponent.Alignment.CENTER); header.setDefaultVerticalComponentAlignment(FlexComponent.Alignment.CENTER);
header.setJustifyContentMode(FlexComponent.JustifyContentMode.END); // Align items to the right header.setJustifyContentMode(FlexComponent.JustifyContentMode.END); // Align items to the start (left)
header.getStyle().set("padding", "0 10px"); // Add padding around the header header.getStyle().set("padding", "0 10px"); // Add padding around the header if needed
addToNavbar(true, toggle, viewTitle, exportToCSVButton);
addToNavbar(true, toggle, viewTitle);
addToNavbar(header); addToNavbar(header);
} }
@ -115,26 +74,18 @@ public class MainLayout extends AppLayout {
private SideNav createNavigation() { private SideNav createNavigation() {
SideNav nav = new SideNav(); SideNav nav = new SideNav();
// Common menu items
nav.addItem(new SideNavItem("Expenses", ExpensesView.class, LineAwesomeIcon.MONEY_BILL_SOLID.create())); nav.addItem(new SideNavItem("Expenses", ExpensesView.class, LineAwesomeIcon.MONEY_BILL_SOLID.create()));
nav.addItem(new SideNavItem("Categories", CategoriesView.class, LineAwesomeIcon.FOLDER.create())); nav.addItem(new SideNavItem("Categories", CategoriesView.class, LineAwesomeIcon.FOLDER.create()));
nav.addItem(new SideNavItem("People", PeopleView.class, LineAwesomeIcon.USER.create())); nav.addItem(new SideNavItem("People", PeopleView.class, LineAwesomeIcon.USER.create()));
nav.addItem(new SideNavItem("Events", EventsView.class, LineAwesomeIcon.BANDCAMP.create()));
nav.addItem(new SideNavItem("Dashboard", DashboardView.class, LineAwesomeIcon.CHART_LINE_SOLID.create())); nav.addItem(new SideNavItem("Dashboard", DashboardView.class, LineAwesomeIcon.CHART_LINE_SOLID.create()));
// Check user roles before adding sensitive menu items
if (isUserAdmin())
nav.addItem(new SideNavItem("Users", UsersView.class, LineAwesomeIcon.USER_LOCK_SOLID.create()));
nav.addItem(new SideNavItem("Settings", SettingsView.class, LineAwesomeIcon.COG_SOLID.create()));
return nav; return nav;
} }
private boolean isUserAdmin() {
final var user = userService.getLoggedInUser();
return user.getRoles().contains("ROLE_ADMIN");
}
private Footer createFooter() { private Footer createFooter() {
return new Footer(); return new Footer();
} }
@ -142,10 +93,6 @@ public class MainLayout extends AppLayout {
protected void afterNavigation() { protected void afterNavigation() {
super.afterNavigation(); super.afterNavigation();
viewTitle.setText(getCurrentPageTitle()); viewTitle.setText(getCurrentPageTitle());
// Show or hide the Export to CSV button based on the current view
boolean isExpensesView = getContent().getClass().equals(ExpensesView.class);
exportToCSVButton.setVisible(isExpensesView);
} }
private String getCurrentPageTitle() { private String getCurrentPageTitle() {

View file

@ -2,7 +2,6 @@ package com.application.munera.views.categories;
import com.application.munera.data.Category; import com.application.munera.data.Category;
import com.application.munera.services.CategoryService; import com.application.munera.services.CategoryService;
import com.application.munera.services.UserService;
import com.application.munera.views.MainLayout; import com.application.munera.views.MainLayout;
import com.vaadin.flow.component.UI; import com.vaadin.flow.component.UI;
import com.vaadin.flow.component.button.Button; import com.vaadin.flow.component.button.Button;
@ -21,12 +20,10 @@ import com.vaadin.flow.component.textfield.TextArea;
import com.vaadin.flow.component.textfield.TextField; import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.data.binder.BeanValidationBinder; import com.vaadin.flow.data.binder.BeanValidationBinder;
import com.vaadin.flow.data.binder.ValidationException; import com.vaadin.flow.data.binder.ValidationException;
import com.vaadin.flow.router.BeforeEnterEvent; import com.vaadin.flow.router.*;
import com.vaadin.flow.router.BeforeEnterObserver; import com.vaadin.flow.spring.data.VaadinSpringDataHelpers;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import jakarta.annotation.security.PermitAll; import jakarta.annotation.security.PermitAll;
import org.springframework.dao.DataIntegrityViolationException; import org.springframework.data.domain.PageRequest;
import org.springframework.orm.ObjectOptimisticLockingFailureException; import org.springframework.orm.ObjectOptimisticLockingFailureException;
import java.util.Optional; import java.util.Optional;
@ -50,20 +47,20 @@ public class CategoriesView extends Div implements BeforeEnterObserver {
private Category category; private Category category;
private final CategoryService categoryService; private final CategoryService categoryService;
private final UserService userService;
private TextField name; private TextField name;
private TextArea description; private TextArea description;
public CategoriesView(CategoryService categoryService, UserService userService) { public CategoriesView(CategoryService categoryService) {
this.categoryService = categoryService; this.categoryService = categoryService;
this.userService = userService;
final var userId = this.userService.getLoggedInUser().getId();
addClassNames("expenses-view"); addClassNames("expenses-view");
// Create UI // Create UI
SplitLayout splitLayout = new SplitLayout(); SplitLayout splitLayout = new SplitLayout();
createGridLayout(splitLayout); createGridLayout(splitLayout);
createEditorLayout(splitLayout); createEditorLayout(splitLayout);
add(splitLayout); add(splitLayout);
// Configure Grid // Configure Grid
@ -71,7 +68,9 @@ public class CategoriesView extends Div implements BeforeEnterObserver {
grid.addColumn(Category::getDescription).setHeader("Description").setSortable(true); grid.addColumn(Category::getDescription).setHeader("Description").setSortable(true);
grid.getColumns().forEach(col -> col.setAutoWidth(true)); grid.getColumns().forEach(col -> col.setAutoWidth(true));
grid.setItems(this.categoryService.findAllByUserId(userId)); grid.setItems(query -> categoryService.list(
PageRequest.of(query.getPage(), query.getPageSize(), VaadinSpringDataHelpers.toSpringDataSort(query)))
.stream());
grid.addThemeVariants(GridVariant.LUMO_NO_BORDER); grid.addThemeVariants(GridVariant.LUMO_NO_BORDER);
// when a row is selected or deselected, populate form // when a row is selected or deselected, populate form
@ -87,11 +86,8 @@ public class CategoriesView extends Div implements BeforeEnterObserver {
binder = new BeanValidationBinder<>(Category.class); binder = new BeanValidationBinder<>(Category.class);
// Bind fields. This is where you'd define e.g. validation rules // Bind fields. This is where you'd define e.g. validation rules
binder.bindInstanceFields(this);
binder.forField(name)
.asRequired("Name is required")
.bind(Category::getName, Category::setName);
binder.bindInstanceFields(this);
cancel.addClickListener(e -> { cancel.addClickListener(e -> {
clearForm(); clearForm();
@ -104,7 +100,7 @@ public class CategoriesView extends Div implements BeforeEnterObserver {
this.category = new Category(); this.category = new Category();
} }
binder.writeBean(this.category); binder.writeBean(this.category);
categoryService.update(this.category, userId); categoryService.update(this.category);
clearForm(); clearForm();
refreshGrid(); refreshGrid();
Notification.show("Data updated"); Notification.show("Data updated");
@ -115,13 +111,13 @@ public class CategoriesView extends Div implements BeforeEnterObserver {
n.setPosition(Notification.Position.MIDDLE); n.setPosition(Notification.Position.MIDDLE);
n.addThemeVariants(NotificationVariant.LUMO_ERROR); n.addThemeVariants(NotificationVariant.LUMO_ERROR);
} catch (ValidationException validationException) { } catch (ValidationException validationException) {
Notification.show("Failed to update the category. Check again that all values are valid"); Notification.show("Failed to update the data. Check again that all values are valid");
} }
}); });
delete.addClickListener(e -> { delete.addClickListener(e -> {
try { try {
if (this.category == null) throw new IllegalStateException("Category is null!"); if (this.category == null) throw new RuntimeException("Category is null!"); //TODO: create proper exception
categoryService.delete(this.category); categoryService.delete(this.category);
clearForm(); clearForm();
refreshGrid(); refreshGrid();
@ -132,11 +128,6 @@ public class CategoriesView extends Div implements BeforeEnterObserver {
"Error updating the data. Somebody else has updated the record while you were making changes."); "Error updating the data. Somebody else has updated the record while you were making changes.");
n.setPosition(Notification.Position.MIDDLE); n.setPosition(Notification.Position.MIDDLE);
n.addThemeVariants(NotificationVariant.LUMO_ERROR); n.addThemeVariants(NotificationVariant.LUMO_ERROR);
} catch (DataIntegrityViolationException ex) {
Notification n = Notification.show(
"Cannot delete this category as it is associated with existing expenses.");
n.setPosition(Notification.Position.MIDDLE);
n.addThemeVariants(NotificationVariant.LUMO_ERROR);
} }
}); });
} }

View file

@ -2,13 +2,10 @@ package com.application.munera.views.dashboard;
import com.application.munera.data.Expense; import com.application.munera.data.Expense;
import com.application.munera.data.Person; import com.application.munera.data.Person;
import com.application.munera.data.User;
import com.application.munera.facades.PersonFacade;
import com.application.munera.services.ExpenseService; import com.application.munera.services.ExpenseService;
import com.application.munera.services.UserService; import com.application.munera.services.PersonService;
import com.application.munera.views.MainLayout; import com.application.munera.views.MainLayout;
import com.nimbusds.jose.shaded.gson.Gson; import com.nimbusds.jose.shaded.gson.Gson;
import com.vaadin.flow.component.combobox.ComboBox;
import com.vaadin.flow.component.html.Div; import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout; import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout; import com.vaadin.flow.component.orderedlayout.VerticalLayout;
@ -17,7 +14,6 @@ import com.vaadin.flow.router.Route;
import jakarta.annotation.security.PermitAll; import jakarta.annotation.security.PermitAll;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.Month;
import java.time.Year; import java.time.Year;
import java.time.YearMonth; import java.time.YearMonth;
import java.time.format.TextStyle; import java.time.format.TextStyle;
@ -31,42 +27,22 @@ import java.util.stream.Collectors;
public class DashboardView extends Div { public class DashboardView extends Div {
private final ExpenseService expenseService; private final ExpenseService expenseService;
private final PersonFacade personFacade; private final PersonService personService;
private final User loggedUser;
private final Person loggedPerson;
private final ComboBox<Integer> yearComboBox;
public DashboardView(ExpenseService expenseService, UserService userService, PersonFacade personFacade) { public DashboardView(final ExpenseService expenseService, final PersonService personService) {
this.expenseService = expenseService; this.expenseService = expenseService;
this.personFacade = personFacade; this.personService = personService;
loggedUser = userService.getLoggedInUser();
loggedPerson = personFacade.getLoggedInPerson();
addClassName("highcharts-view"); // Optional CSS class for styling addClassName("highcharts-view"); // Optional CSS class for styling
// Fetch available years from the database
List<Integer> availableYears = this.expenseService.getAvailableExpenseYearsForUser(loggedUser.getId());
// Initialize the ComboBox for year selection
yearComboBox = new ComboBox<>();
yearComboBox.setItems(availableYears);
yearComboBox.setValue(Year.now().getValue()); // Default to current year
yearComboBox.setWidth("200px");
// Add listener to update charts when a new year is selected
yearComboBox.addValueChangeListener(event -> {
if (event.getValue() != null) updateCharts(Year.of(event.getValue()));
});
VerticalLayout mainLayout = new VerticalLayout(); VerticalLayout mainLayout = new VerticalLayout();
mainLayout.add(yearComboBox);
mainLayout.setSizeFull(); mainLayout.setSizeFull();
mainLayout.getStyle().set("padding", "5px"); // Add padding to main layout mainLayout.getStyle().set("padding", "10px"); // Add padding to main layout
// Create a horizontal layout for the top row // Create a horizontal layout for the top row
HorizontalLayout topRowLayout = new HorizontalLayout(); HorizontalLayout topRowLayout = new HorizontalLayout();
topRowLayout.setSizeFull(); topRowLayout.setSizeFull();
topRowLayout.setHeight("50%"); // Make sure the top row occupies half of the page height topRowLayout.setHeight("50%"); // Make sure the top row occupies half of the page height
topRowLayout.getStyle().set("padding", "5px"); // Add padding to top row topRowLayout.getStyle().set("padding", "10px"); // Add padding to top row
// Create and add the existing bar chart to the top left // Create and add the existing bar chart to the top left
Div barChartDiv = new Div(); Div barChartDiv = new Div();
@ -92,7 +68,7 @@ public class DashboardView extends Div {
HorizontalLayout bottomRowLayout = new HorizontalLayout(); HorizontalLayout bottomRowLayout = new HorizontalLayout();
bottomRowLayout.setSizeFull(); bottomRowLayout.setSizeFull();
bottomRowLayout.setHeight("50%"); // Make sure the bottom row occupies the other half of the page height bottomRowLayout.setHeight("50%"); // Make sure the bottom row occupies the other half of the page height
bottomRowLayout.getStyle().set("padding", "5px"); // Add padding to bottom row bottomRowLayout.getStyle().set("padding", "10px"); // Add padding to bottom row
// Create the bottom left chart // Create the bottom left chart
Div bottomLeftChartDiv = new Div(); Div bottomLeftChartDiv = new Div();
@ -113,119 +89,68 @@ public class DashboardView extends Div {
bottomRowLayout.add(bottomRightChartDiv); bottomRowLayout.add(bottomRightChartDiv);
mainLayout.add(bottomRowLayout); mainLayout.add(bottomRowLayout);
add(mainLayout); add(mainLayout);
updateCharts(Year.now());
}
// Update the charts based on the selected year String barChartJs = generateBarChartScript();
private void updateCharts(Year year) { String pieChartJs = generatePieChartScript();
String barChartJs = generateBarChartScript(year); String bottomLeftChartJs = generateNegativeColumnChartScript();
String pieChartJs = generatePieChartScript(year); String bottomRightChartJs = generateExpensesOverTimeByCategoryScript();
String negativeColumnChartJs = generateNegativeColumnChartScript();
String expensesOverTimeByCategoryChart = generateExpensesOverTimeByCategoryScript(year);
// Execute the JavaScript to update the charts // Execute the JavaScript to initialize the charts
getElement().executeJs(barChartJs); getElement().executeJs(barChartJs);
getElement().executeJs(pieChartJs); getElement().executeJs(pieChartJs);
getElement().executeJs(negativeColumnChartJs); getElement().executeJs(bottomLeftChartJs);
getElement().executeJs(expensesOverTimeByCategoryChart); getElement().executeJs(bottomRightChartJs);
} }
private String generateBarChartScript(Year year) { private String generateBarChartScript() {
List<Expense> expenses = expenseService.fetchExpensesForDashboard(loggedPerson, year); List<Expense> expenses = expenseService.findAllByYear(Year.now().getValue());
// Create a map to store data by month and category // Prepare data for Highcharts
Map<String, Map<String, Double>> monthlyCategoryData = new LinkedHashMap<>(); Map<String, Double> monthlyData = new LinkedHashMap<>();
YearMonth currentYearMonth = YearMonth.now().withMonth(1); // Start from January
// Initialize all months (from January to December) for each category
List<String> monthNames = new ArrayList<>();
for (int i = 1; i <= 12; i++) { for (int i = 1; i <= 12; i++) {
String monthName = Month.of(i).getDisplayName(TextStyle.SHORT, Locale.ENGLISH); String monthName = currentYearMonth.getMonth().getDisplayName(TextStyle.SHORT, Locale.ENGLISH);
monthNames.add(monthName); monthlyData.put(monthName, 0.0);
monthlyCategoryData.putIfAbsent(monthName, new LinkedHashMap<>()); currentYearMonth = currentYearMonth.plusMonths(1); // Move to the next month
} }
// Populate the map with actual expense data // Populate map with actual data
for (Expense expense : expenses) { for (Expense expense : expenses) {
String monthName = expense.getDate().getMonth().getDisplayName(TextStyle.SHORT, Locale.ENGLISH); String monthName = expense.getDate().getMonth().getDisplayName(TextStyle.SHORT, Locale.ENGLISH);
String categoryName = expense.getCategory().getName();
Double amount = expense.getCost().doubleValue(); Double amount = expense.getCost().doubleValue();
monthlyData.put(monthName, monthlyData.get(monthName) + amount);
monthlyCategoryData.putIfAbsent(monthName, new LinkedHashMap<>());
Map<String, Double> categoryData = monthlyCategoryData.get(monthName);
categoryData.put(categoryName, categoryData.getOrDefault(categoryName, 0.0) + amount);
} }
// Prepare series data for Highcharts, with each category being a separate series // Prepare series data for Highcharts
Map<String, Map<String, Double>> categoryMonthlyData = new LinkedHashMap<>(); StringBuilder data = new StringBuilder("[");
for (String monthName : monthNames) { for (Map.Entry<String, Double> entry : monthlyData.entrySet()) {
Map<String, Double> monthData = monthlyCategoryData.getOrDefault(monthName, new LinkedHashMap<>()); data.append(entry.getValue()).append(",");
for (Map.Entry<String, Double> entry : monthData.entrySet()) {
String categoryName = entry.getKey();
Double amount = entry.getValue();
categoryMonthlyData.putIfAbsent(categoryName, new LinkedHashMap<>());
categoryMonthlyData.get(categoryName).put(monthName, amount);
}
} }
data.setCharAt(data.length() - 1, ']'); // Replace last comma with closing bracket
// Build the series data for each category // Generate JavaScript initialization
StringBuilder seriesData = new StringBuilder("[");
for (Map.Entry<String, Map<String, Double>> entry : categoryMonthlyData.entrySet()) {
String categoryName = entry.getKey();
Map<String, Double> monthData = entry.getValue();
seriesData.append("{");
seriesData.append("name: '").append(categoryName).append("',");
seriesData.append("data: [");
for (String monthName : monthNames) {
seriesData.append(monthData.getOrDefault(monthName, 0.0)).append(",");
}
seriesData.setLength(seriesData.length() - 1); // Remove trailing comma
seriesData.append("], stack: 'expenses'");
seriesData.append("},");
}
seriesData.setLength(seriesData.length() - 1); // Remove trailing comma
seriesData.append("]");
// Generate the JavaScript for the stacked column chart
return "Highcharts.chart('barChart', {" + return "Highcharts.chart('barChart', {" +
"chart: { type: 'column' }, " + "chart: {" +
"title: { text: 'Monthly Expenses by Category for " + Year.now().getValue() + "' }, " + "type: 'column'" +
"xAxis: { categories: " + new Gson().toJson(monthNames) + " }, " +
"yAxis: { " +
"min: 0, " +
"title: { text: 'Total Expense' }, " +
"stackLabels: { " +
"enabled: true, " +
"style: { fontWeight: 'bold', fontSize: '14px', color: 'white', textOutline: '1px contrast', " +
"backgroundColor: 'rgba(0,0,0,0.75)', padding: 4, borderRadius: 3 }, " + // Background and border for better visibility
"formatter: function() { " +
" if (this.total > 0) { return this.total; } else { return ''; }" + // Only show total if greater than 0
"}" +
"} " +
"}, " +
"plotOptions: { " +
"column: { " +
"stacking: 'normal', " +
"dataLabels: { " +
"enabled: true, " +
"formatter: function() { " +
" if (this.y > 0) { return this.y; } else { return ''; }" + // Only show data label if value > 0
"}," + "}," +
"color: 'black', " + // Set data label color to black "title: {" +
"style: { fontSize: '12px', fontWeight: 'bold' }" + // Customize individual labels' appearance "text: 'Monthly Expenses for " + Year.now().getValue() + "'" +
"} " + "}," +
"} " + "xAxis: {" +
"}, " + "categories: " + new Gson().toJson(monthlyData.keySet()) + // Categories are the month names
"series: " + seriesData + " " + "}," +
"series: [{" +
"name: 'Expenses'," +
"data: " + data + // Use the data fetched from DB
"}]" +
"});"; "});";
} }
private String generatePieChartScript(Year year) { private String generatePieChartScript() {
List<Expense> expenses = expenseService.fetchExpensesForDashboard(loggedPerson, year); List<Expense> expenses = expenseService.findAllByYear(Year.now().getValue());
// Group expenses by category name and sum their costs // Group expenses by category name and sum their costs
Map<String, Double> categoryData = expenses.stream() Map<String, Double> categoryData = expenses.stream()
@ -242,7 +167,7 @@ public class DashboardView extends Div {
} }
data.setCharAt(data.length() - 1, ']'); // Replace last comma with closing bracket data.setCharAt(data.length() - 1, ']'); // Replace last comma with closing bracket
// Generate JavaScript initialization with percentage tooltip // Generate JavaScript initialization
return "Highcharts.chart('pieChart', {" + return "Highcharts.chart('pieChart', {" +
"chart: {" + "chart: {" +
"type: 'pie'" + "type: 'pie'" +
@ -250,16 +175,9 @@ public class DashboardView extends Div {
"title: {" + "title: {" +
"text: 'Expenses by Category for " + Year.now().getValue() + "'" + "text: 'Expenses by Category for " + Year.now().getValue() + "'" +
"}," + "}," +
"tooltip: {" + // Tooltip configuration to show percentage
"pointFormat: '{series.name}: <b>{point.percentage:.1f}%</b> ({point.y:.2f})'" +
"}," +
"plotOptions: {" + "plotOptions: {" +
"pie: {" + "pie: {" +
"size: '80%'," + // Adjust size to make the pie chart larger "size: '80%'" + // Adjust size to make the pie chart larger
"dataLabels: {" + // Enable data labels
"enabled: true," +
"format: '{point.name}: {point.percentage:.1f}%'" + // Show percentage next to each slice
"}" +
"}" + "}" +
"}," + "}," +
"series: [{" + "series: [{" +
@ -271,22 +189,17 @@ public class DashboardView extends Div {
} }
private String generateNegativeColumnChartScript() { private String generateNegativeColumnChartScript() {
final var people = personFacade.findAllExcludeLoggedUser(loggedUser); final var people = personService.findAll().stream()
// Create a map to store person names and their balances .filter(person -> personService.calculateNetBalance(person).compareTo(BigDecimal.ZERO) != 0)
Map<String, Double> personData = people.stream() .toList();
.map(person -> { if (people.isEmpty()) return generatePlaceholderChartScript("bottomLeftChart", "No Data Available");
BigDecimal balance = personFacade.calculateNetBalance(person);
// Return an array with the persons first name and the balance Map<String, Double> personData = new LinkedHashMap<>();
return new Object[]{person.getFirstName(), balance};
}) for (Person person : people) {
.filter(entry -> ((BigDecimal) entry[1]).compareTo(BigDecimal.ZERO) != 0) BigDecimal balance = personService.calculateNetBalance(person);
.collect(Collectors.toMap( personData.put(person.getFirstName(), balance.doubleValue());
entry -> (String) entry[0], }
entry -> ((BigDecimal) entry[1]).negate().doubleValue(),
(existing, replacement) -> existing,
LinkedHashMap::new // Preserves insertion order
));
if (personData.isEmpty()) return generatePlaceholderChartScript("bottomLeftChart", "All Payments Settled");
// Prepare series data for Highcharts with conditional coloring // Prepare series data for Highcharts with conditional coloring
StringBuilder data = new StringBuilder("["); StringBuilder data = new StringBuilder("[");
@ -295,18 +208,18 @@ public class DashboardView extends Div {
String color = value >= 0 ? "#90EE90" : "#FF9999"; // Green for positive, red for negative String color = value >= 0 ? "#90EE90" : "#FF9999"; // Green for positive, red for negative
data.append("{ y: ").append(value).append(", color: '").append(color).append("' },"); data.append("{ y: ").append(value).append(", color: '").append(color).append("' },");
} }
data.setCharAt(data.length() - 1, ']'); // Replace the last comma with a closing bracket data.setCharAt(data.length() - 1, ']'); // Replace last comma with closing bracket
// Generate JavaScript initialization // Generate JavaScript initialization
return "Highcharts.chart('bottomLeftChart', {" + return "Highcharts.chart('bottomLeftChart', {" +
"chart: {" + "chart: {" +
"type: 'column'," + "type: 'column'," + // Specify the chart type as column
"}," + "}," +
"title: {" + "title: {" +
"text: 'Net Balances by Person'" + "text: 'Net Balances by Person'" +
"}," + "}," +
"xAxis: {" + "xAxis: {" +
"categories: " + new Gson().toJson(personData.keySet()) + "categories: " + new Gson().toJson(personData.keySet()) + // Categories are the person names
"}," + "}," +
"yAxis: {" + "yAxis: {" +
"title: {" + "title: {" +
@ -318,25 +231,16 @@ public class DashboardView extends Div {
"color: '#808080'" + "color: '#808080'" +
"}]" + "}]" +
"}," + "}," +
"plotOptions: {" + "plotOptions: {" + // Add plotOptions to configure the column width
"column: {" + "column: {" +
"pointWidth: 50," + "pointWidth: 50" + // Adjust the width of the columns (in pixels)
"threshold: 0" +
"}" +
"}," +
"tooltip: {" + // Combine default point format with a custom label
"useHTML: true," +
"formatter: function() {" +
"var label = this.y >= 0 ? 'Credit: ' : 'Debit: '; " +
"return '<span style=\"color:' + this.point.color + '\">\u25CF</span> ' + '<b>' + this.x + '</b><br/>' + label + Math.abs(this.y);" +
"}" + "}" +
"}," + "}," +
"series: [{" + "series: [{" +
"name: 'Balance'," + "name: 'Balance'," +
"data: " + data + "data: " + data + // Use the data fetched from DB
"}]" + "}]" +
"});"; "});"; }
}
private String generatePlaceholderChartScript(String divId, String title) { private String generatePlaceholderChartScript(String divId, String title) {
return "Highcharts.chart('" + divId + "', {" + return "Highcharts.chart('" + divId + "', {" +
@ -353,8 +257,8 @@ public class DashboardView extends Div {
"});"; "});";
} }
private String generateExpensesOverTimeByCategoryScript(Year year) { private String generateExpensesOverTimeByCategoryScript() {
List<Expense> expenses = expenseService.fetchExpensesForDashboard(loggedPerson, year); List<Expense> expenses = expenseService.findAllByYear(Year.now().getValue());
// Group expenses by category and by month // Group expenses by category and by month
Map<String, Map<String, Double>> categoryMonthlyData = new LinkedHashMap<>(); Map<String, Map<String, Double>> categoryMonthlyData = new LinkedHashMap<>();

View file

@ -0,0 +1,213 @@
package com.application.munera.views.events;
import com.application.munera.data.Event;
import com.application.munera.data.Person;
import com.application.munera.services.EventService;
import com.application.munera.services.PersonService;
import com.application.munera.views.MainLayout;
import com.vaadin.flow.component.UI;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.combobox.MultiSelectComboBox;
import com.vaadin.flow.component.dependency.Uses;
import com.vaadin.flow.component.formlayout.FormLayout;
import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.grid.GridVariant;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.icon.Icon;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.notification.NotificationVariant;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.splitlayout.SplitLayout;
import com.vaadin.flow.component.textfield.TextArea;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.data.binder.BeanValidationBinder;
import com.vaadin.flow.data.binder.ValidationException;
import com.vaadin.flow.router.BeforeEnterEvent;
import com.vaadin.flow.router.BeforeEnterObserver;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import com.vaadin.flow.spring.data.VaadinSpringDataHelpers;
import jakarta.annotation.security.PermitAll;
import org.springframework.data.domain.PageRequest;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import java.util.Optional;
@PageTitle("Events")
@PermitAll
@Route(value = "events/:eventID?/:action?(edit)", layout = MainLayout.class)
@Uses(Icon.class)
public class EventsView extends Div implements BeforeEnterObserver {
private static final String EVENT_ID = "eventID";
private static final String EVENT_EDIT_ROUTE_TEMPLATE = "events/%s/edit";
private final Grid<Event> grid = new Grid<>(Event.class, false);
private final Button cancel = new Button("Cancel");
private final Button save = new Button("Save");
private final Button delete = new Button("Delete");
private final BeanValidationBinder<Event> binder;
private Event event;
private final EventService eventService;
private final PersonService personService;
private TextField name;
private TextArea description;
private MultiSelectComboBox<Person> participants;
public EventsView(EventService eventService, PersonService personService) {
this.eventService = eventService;
this.personService = personService;
addClassNames("expenses-view");
// Create UI
SplitLayout splitLayout = new SplitLayout();
createGridLayout(splitLayout);
createEditorLayout(splitLayout);
add(splitLayout);
// Configure Grid
grid.addColumn(Event::getName).setHeader("Name").setSortable(true);
grid.addColumn(Event::getDescription).setHeader("Description").setSortable(true);
grid.getColumns().forEach(col -> col.setAutoWidth(true));
grid.setItems(query -> eventService.list(
PageRequest.of(query.getPage(), query.getPageSize(), VaadinSpringDataHelpers.toSpringDataSort(query)))
.stream());
grid.addThemeVariants(GridVariant.LUMO_NO_BORDER);
// when a row is selected or deselected, populate form
grid.asSingleSelect().addValueChangeListener(event -> {
if (event.getValue() != null) UI.getCurrent().navigate(String.format(EVENT_EDIT_ROUTE_TEMPLATE, event.getValue().getId()));
else {
clearForm();
UI.getCurrent().navigate(EventsView.class);
}
});
// Configure Form
binder = new BeanValidationBinder<>(Event.class);
// Bind fields. This is where you'd define e.g. validation rules
binder.bindInstanceFields(this);
cancel.addClickListener(e -> {
clearForm();
refreshGrid();
});
save.addClickListener(e -> {
try {
if (this.event == null) {
this.event = new Event();
}
binder.writeBean(this.event);
eventService.update(this.event);
clearForm();
refreshGrid();
Notification.show("Data updated");
UI.getCurrent().navigate(EventsView.class);
} catch (ObjectOptimisticLockingFailureException exception) {
Notification n = Notification.show(
"Error updating the data. Somebody else has updated the record while you were making changes.");
n.setPosition(Notification.Position.MIDDLE);
n.addThemeVariants(NotificationVariant.LUMO_ERROR);
} catch (ValidationException validationException) {
Notification.show("Failed to update the data. Check again that all values are valid");
}
});
delete.addClickListener(e -> {
try {
if (this.event == null) throw new RuntimeException("Event is null!"); //TODO: create proper exception
eventService.delete(this.event);
clearForm();
refreshGrid();
Notification.show("Data deleted");
UI.getCurrent().navigate(EventsView.class);
} catch (ObjectOptimisticLockingFailureException exception) {
Notification n = Notification.show(
"Error updating the data. Somebody else has updated the record while you were making changes.");
n.setPosition(Notification.Position.MIDDLE);
n.addThemeVariants(NotificationVariant.LUMO_ERROR);
}
});
}
@Override
public void beforeEnter(BeforeEnterEvent event) {
Optional<Long> eventId = event.getRouteParameters().get(EVENT_ID).map(Long::parseLong);
if (eventId.isPresent()) {
Optional<Event> eventFromBackend = eventService.findById(eventId.get());
if (eventFromBackend.isPresent()) {
populateForm(eventFromBackend.get());
} else {
Notification.show(
String.format("The requested event was not found, ID = %s", eventId.get()), 3000,
Notification.Position.BOTTOM_START);
// when a row is selected but the data is no longer available,
// refresh grid
refreshGrid();
event.forwardTo(EventsView.class);
}
}
}
private void createEditorLayout(SplitLayout splitLayout) {
Div editorLayoutDiv = new Div();
editorLayoutDiv.setClassName("editor-layout");
Div editorDiv = new Div();
editorDiv.setClassName("editor");
editorLayoutDiv.add(editorDiv);
FormLayout formLayout = new FormLayout();
name = new TextField("Name");
description = new TextArea("Description");
participants = new MultiSelectComboBox<>("Participants");
participants.setItems(personService.findAll());
participants.setItemLabelGenerator(Person::getFirstName);
formLayout.add(name, description, participants);
editorDiv.add(formLayout);
createButtonLayout(editorLayoutDiv);
splitLayout.addToSecondary(editorLayoutDiv);
}
private void createButtonLayout(Div editorLayoutDiv) {
HorizontalLayout buttonLayout = new HorizontalLayout();
buttonLayout.setClassName("button-layout");
cancel.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
save.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
delete.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
buttonLayout.add(save, delete, cancel);
editorLayoutDiv.add(buttonLayout);
}
private void createGridLayout(SplitLayout splitLayout) {
Div wrapper = new Div();
wrapper.setClassName("grid-wrapper");
splitLayout.addToPrimary(wrapper);
wrapper.add(grid);
}
private void refreshGrid() {
grid.select(null);
grid.getDataProvider().refreshAll();
}
private void clearForm() {
populateForm(null);
}
private void populateForm(Event value) {
this.event = value;
binder.readBean(this.event);
}
}

View file

@ -1,14 +1,10 @@
package com.application.munera.views.expenses; package com.application.munera.views.expenses;
import com.application.munera.data.Category; import com.application.munera.data.*;
import com.application.munera.data.Expense;
import com.application.munera.data.enums.PeriodUnit;
import com.application.munera.data.Person;
import com.application.munera.facades.PersonFacade;
import com.application.munera.services.CategoryService; import com.application.munera.services.CategoryService;
import com.application.munera.services.EventService;
import com.application.munera.services.ExpenseService; import com.application.munera.services.ExpenseService;
import com.application.munera.services.UserService; import com.application.munera.services.PersonService;
import com.application.munera.services.ViewsService;
import com.application.munera.views.MainLayout; import com.application.munera.views.MainLayout;
import com.vaadin.flow.component.UI; import com.vaadin.flow.component.UI;
import com.vaadin.flow.component.button.Button; import com.vaadin.flow.component.button.Button;
@ -21,30 +17,27 @@ import com.vaadin.flow.component.dependency.Uses;
import com.vaadin.flow.component.formlayout.FormLayout; import com.vaadin.flow.component.formlayout.FormLayout;
import com.vaadin.flow.component.grid.GridVariant; import com.vaadin.flow.component.grid.GridVariant;
import com.vaadin.flow.component.html.Div; import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.icon.Icon; import com.vaadin.flow.component.icon.Icon;
import com.vaadin.flow.component.notification.Notification; import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.notification.Notification.Position; import com.vaadin.flow.component.notification.Notification.Position;
import com.vaadin.flow.component.notification.NotificationVariant; import com.vaadin.flow.component.notification.NotificationVariant;
import com.vaadin.flow.component.orderedlayout.FlexComponent;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout; import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.splitlayout.SplitLayout; import com.vaadin.flow.component.splitlayout.SplitLayout;
import com.vaadin.flow.component.textfield.TextArea; import com.vaadin.flow.component.textfield.TextArea;
import com.vaadin.flow.component.textfield.TextField; import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.data.binder.BeanValidationBinder; import com.vaadin.flow.data.binder.BeanValidationBinder;
import com.vaadin.flow.data.binder.ValidationException; import com.vaadin.flow.data.binder.ValidationException;
import com.vaadin.flow.data.converter.StringToBigDecimalConverter;
import com.vaadin.flow.data.renderer.ComponentRenderer; import com.vaadin.flow.data.renderer.ComponentRenderer;
import com.vaadin.flow.data.value.ValueChangeMode;
import com.vaadin.flow.router.*; import com.vaadin.flow.router.*;
import jakarta.annotation.security.PermitAll; import jakarta.annotation.security.PermitAll;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.orm.ObjectOptimisticLockingFailureException; import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.vaadin.klaudeta.PaginatedGrid; import org.vaadin.klaudeta.PaginatedGrid;
import java.math.BigDecimal; import java.util.HashSet;
import java.util.Objects; import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.Set;
@PermitAll @PermitAll
@PageTitle("Expenses") @PageTitle("Expenses")
@ -57,41 +50,35 @@ public class ExpensesView extends Div implements BeforeEnterObserver {
private static final String EXPENSE_EDIT_ROUTE_TEMPLATE = "/%s/edit"; private static final String EXPENSE_EDIT_ROUTE_TEMPLATE = "/%s/edit";
private final PaginatedGrid<Expense, Objects> grid = new PaginatedGrid<>(); private final PaginatedGrid<Expense, Objects> grid = new PaginatedGrid<>();
private final TextField nameFilter = new TextField();
private final MultiSelectComboBox<Category> categoryFilter = new MultiSelectComboBox<>();
private final Button cancel = new Button("Cancel"); private final Button cancel = new Button("Cancel");
private final Button save = new Button("Save"); private final Button save = new Button("Save");
private final Button delete = new Button("Delete"); private final Button delete = new Button("Delete");
private final BeanValidationBinder<Expense> binder; private final BeanValidationBinder<Expense> binder;
private Expense expense; private Expense expense;
private final Long userId;
private final ExpenseService expenseService; private final ExpenseService expenseService;
private final PersonFacade personFacade;
private final CategoryService categoryService; private final CategoryService categoryService;
private final ViewsService viewsService; private final PersonService personService;
private final UserService userService; private final EventService eventService;
private TextField name; private TextField name;
private TextField cost; private TextField cost;
private ComboBox<Category> category; private ComboBox<Category> category;
private TextArea description; private TextArea description;
private Checkbox isPeriodic; private Checkbox isPeriodic;
private Checkbox isPaid; private Checkbox isResolved;
private ComboBox<PeriodUnit> periodUnit; private ComboBox<PeriodUnit> periodUnit;
private TextField periodInterval; private TextField periodInterval;
private DatePicker date; private DatePicker date;
private ComboBox<Person> payer; private MultiSelectComboBox<Person> creditors;
private ComboBox<Person> beneficiary; private MultiSelectComboBox<Person> debtors;
private ComboBox<Event> event;
@Autowired public ExpensesView(ExpenseService expenseService, CategoryService categoryService, PersonService personService, EventService eventService) {
public ExpensesView(ExpenseService expenseService, CategoryService categoryService, ViewsService viewsService, UserService userService, PersonFacade personFacade) {
this.expenseService = expenseService; this.expenseService = expenseService;
this.categoryService = categoryService; this.categoryService = categoryService;
this.viewsService = viewsService; this.personService = personService;
this.userService = userService; this.eventService = eventService;
this.personFacade = personFacade;
this.userId = this.userService.getLoggedInUser().getId();
addClassNames("expenses-view"); addClassNames("expenses-view");
// Create UI // Create UI
@ -106,40 +93,24 @@ public class ExpensesView extends Div implements BeforeEnterObserver {
grid.addColumn(Expense::getName).setHeader("Name").setSortable(true).setSortProperty("name"); grid.addColumn(Expense::getName).setHeader("Name").setSortable(true).setSortProperty("name");
grid.addColumn(Expense::getCost).setHeader("Amount").setSortable(true).setSortProperty("cost"); grid.addColumn(Expense::getCost).setHeader("Amount").setSortable(true).setSortProperty("cost");
grid.addColumn(expenseCategory -> expenseCategory.getCategory().getName()).setHeader("Category").setSortable(true).setSortProperty("category"); grid.addColumn(expenseCategory -> expenseCategory.getCategory().getName()).setHeader("Category").setSortable(true).setSortProperty("category");
grid.addColumn(Expense::getPeriodInterval).setHeader("Period Interval").setSortable(true);
grid.addColumn(Expense::getPeriodUnit).setHeader("Period Unit").setSortable(true);
grid.addColumn(Expense::getDate).setHeader("Date").setSortable(true).setSortProperty("date"); grid.addColumn(Expense::getDate).setHeader("Date").setSortable(true).setSortProperty("date");
grid.addColumn(new ComponentRenderer<>(this.viewsService::createExpenseBadge)).setHeader("Status").setSortable(true); // grid.addColumn(expenseEvent -> expenseEvent.getEvent().getName()).setHeader("Event").setSortable(true);
grid.addColumn(new ComponentRenderer<>(expense1 -> createBadge(expenseService.isExpenseResolved(expense1)))).setHeader("Status").setSortable(true);
grid.getColumns().forEach(col -> col.setAutoWidth(true)); grid.getColumns().forEach(col -> col.setAutoWidth(true));
grid.setItems(this.expenseService.findAllOrderByDateDescending(userId)); grid.setItems(this.expenseService.findAllOrderByDateDescending());
grid.setPaginatorSize(5); grid.setPaginatorSize(5);
grid.setPageSize(22); // setting page size grid.setPageSize(25); // setting page size
grid.addThemeVariants(GridVariant.LUMO_NO_BORDER); grid.addThemeVariants(GridVariant.LUMO_NO_BORDER);
// Filtering setup - Name
nameFilter.setPlaceholder("Filter by Name...");
nameFilter.setClearButtonVisible(true);
nameFilter.setValueChangeMode(ValueChangeMode.LAZY);
nameFilter.addValueChangeListener(e -> this.viewsService.applyNameFilter(nameFilter, userId, grid));
// Filtering setup - Category
categoryFilter.setPlaceholder("Filter by Category...");
categoryFilter.setClearButtonVisible(true);
categoryFilter.setItems(categoryService.findAllByUserId(userId));
categoryFilter.setItemLabelGenerator(Category::getName);
categoryFilter.addValueChangeListener(e -> this.viewsService.applyCategoryFilter(categoryFilter, userId, grid));
// Add filter fields to layout (above the grid)
VerticalLayout layout = new VerticalLayout();
HorizontalLayout filterLayout = new HorizontalLayout(nameFilter, categoryFilter);
filterLayout.setSpacing(true);
filterLayout.setAlignItems(FlexComponent.Alignment.BASELINE);
layout.add(filterLayout, grid);
splitLayout.addToPrimary(layout);
// when a row is selected or deselected, populate form // when a row is selected or deselected, populate form
grid.asSingleSelect().addValueChangeListener(event -> { grid.asSingleSelect().addValueChangeListener(event -> {
if (event.getValue() != null) UI.getCurrent().navigate(String.format(EXPENSE_EDIT_ROUTE_TEMPLATE, event.getValue().getId())); if (event.getValue() != null) {
else { UI.getCurrent().navigate(String.format(EXPENSE_EDIT_ROUTE_TEMPLATE, event.getValue().getId()));
} else {
clearForm(); clearForm();
UI.getCurrent().navigate(ExpensesView.class); UI.getCurrent().navigate(ExpensesView.class);
} }
@ -147,25 +118,13 @@ public class ExpensesView extends Div implements BeforeEnterObserver {
// Configure Form // Configure Form
binder = new BeanValidationBinder<>(Expense.class); binder = new BeanValidationBinder<>(Expense.class);
// Bind fields. This is where you'd define e.g. validation rules
binder.bindInstanceFields(this); binder.bindInstanceFields(this);
binder.forField(name)
.asRequired("Name is required")
.bind(Expense::getName, Expense::setName);
binder.forField(cost)
.asRequired("Cost is required")
.withConverter(new StringToBigDecimalConverter("Invalid cost"))
.withValidator(costValue -> costValue.compareTo(BigDecimal.ONE) > 0, "Cost must be greater than 1")
.bind(Expense::getCost, Expense::setCost);
binder.forField(category)
.asRequired("Category is required")
.bind(Expense::getCategory, Expense::setCategory);
binder.forField(date)
.asRequired("Date is required")
.bind(Expense::getDate, Expense::setDate);
// We set initial value of isPeriodic to true and show period fields // We set initial value of isPeriodic to true and show period fields
isPeriodic.setValue(false); isPeriodic.setValue(false);
isPaid.setValue(false); isResolved.setValue(false);
periodUnit.setVisible(false); periodUnit.setVisible(false);
periodInterval.setVisible(false); periodInterval.setVisible(false);
@ -182,6 +141,22 @@ public class ExpensesView extends Div implements BeforeEnterObserver {
} }
}); });
// Event listeners that will remove the selected creditors from the debtors list and vice versa
// Done so that the user cant create an expense with the same person as creditor and debtor
debtors.addValueChangeListener(event -> {
Set<Person> selectedDebtors = event.getValue();
final var creditorsSet = new HashSet<>(personService.findAll());
creditorsSet.removeIf(selectedDebtors::contains);
creditors.setItems(creditorsSet);
});
creditors.addValueChangeListener(event -> {
Set<Person> selectedCreditors = event.getValue();
final var debtorsSet = new HashSet<>(personService.findAll());
debtorsSet.removeIf(selectedCreditors::contains);
debtors.setItems(debtorsSet);
});
cancel.addClickListener(e -> { cancel.addClickListener(e -> {
clearForm(); clearForm();
refreshGrid(); refreshGrid();
@ -189,9 +164,11 @@ public class ExpensesView extends Div implements BeforeEnterObserver {
save.addClickListener(e -> { save.addClickListener(e -> {
try { try {
if (this.expense == null) this.expense = new Expense(); if (this.expense == null) {
this.expense = new Expense();
}
binder.writeBean(this.expense); binder.writeBean(this.expense);
expenseService.update(this.expense, userId); expenseService.update(this.expense);
clearForm(); clearForm();
refreshGrid(); refreshGrid();
Notification.show("Data updated"); Notification.show("Data updated");
@ -202,13 +179,13 @@ public class ExpensesView extends Div implements BeforeEnterObserver {
n.setPosition(Position.MIDDLE); n.setPosition(Position.MIDDLE);
n.addThemeVariants(NotificationVariant.LUMO_ERROR); n.addThemeVariants(NotificationVariant.LUMO_ERROR);
} catch (ValidationException validationException) { } catch (ValidationException validationException) {
Notification.show("Failed to update the expense. Check again that all values are valid"); Notification.show("Failed to update the data. Check again that all values are valid");
} }
}); });
delete.addClickListener(e -> { delete.addClickListener(e -> {
try { try {
if (Objects.isNull(this.expense)) throw new IllegalStateException("Expense is null!"); if (Objects.isNull(this.expense)) throw new RuntimeException("Expense is null!"); //TODO: create proper exception
expenseService.delete(this.expense.getId()); expenseService.delete(this.expense.getId());
clearForm(); clearForm();
refreshGrid(); refreshGrid();
@ -216,18 +193,15 @@ public class ExpensesView extends Div implements BeforeEnterObserver {
UI.getCurrent().navigate(ExpensesView.class); UI.getCurrent().navigate(ExpensesView.class);
} catch (ObjectOptimisticLockingFailureException exception) { } catch (ObjectOptimisticLockingFailureException exception) {
Notification n = Notification.show( Notification n = Notification.show(
"Error deleting the data. Somebody else has updated the record while you were making changes."); "Error updating the data. Somebody else has updated the record while you were making changes.");
n.setPosition(Position.MIDDLE); n.setPosition(Position.MIDDLE);
n.addThemeVariants(NotificationVariant.LUMO_ERROR); n.addThemeVariants(NotificationVariant.LUMO_ERROR);
} }
}); });
initializeComboBoxes();
} }
@Override @Override
public void beforeEnter(BeforeEnterEvent event) { public void beforeEnter(BeforeEnterEvent event) {
initializeComboBoxes();
Optional<Long> expenseId = event.getRouteParameters().get(EXPENSE_ID).map(Long::parseLong); Optional<Long> expenseId = event.getRouteParameters().get(EXPENSE_ID).map(Long::parseLong);
if (expenseId.isPresent()) { if (expenseId.isPresent()) {
Optional<Expense> expenseFromBackend = expenseService.get(expenseId.get()); Optional<Expense> expenseFromBackend = expenseService.get(expenseId.get());
@ -236,6 +210,8 @@ public class ExpensesView extends Div implements BeforeEnterObserver {
Notification.show( Notification.show(
String.format("The requested expense was not found, ID = %s", expenseId.get()), 3000, String.format("The requested expense was not found, ID = %s", expenseId.get()), 3000,
Notification.Position.BOTTOM_START); Notification.Position.BOTTOM_START);
// when a row is selected but the data is no longer available,
// refresh grid
refreshGrid(); refreshGrid();
event.forwardTo(ExpensesView.class); event.forwardTo(ExpensesView.class);
} }
@ -245,36 +221,39 @@ public class ExpensesView extends Div implements BeforeEnterObserver {
private void createEditorLayout(SplitLayout splitLayout) { private void createEditorLayout(SplitLayout splitLayout) {
Div editorLayoutDiv = new Div(); Div editorLayoutDiv = new Div();
editorLayoutDiv.setClassName("editor-layout"); editorLayoutDiv.setClassName("editor-layout");
Div editorDiv = new Div(); Div editorDiv = new Div();
editorDiv.setClassName("editor"); editorDiv.setClassName("editor");
editorLayoutDiv.add(editorDiv); editorLayoutDiv.add(editorDiv);
final var people = this.personFacade.findAllByUserId(userId);
FormLayout formLayout = new FormLayout(); FormLayout formLayout = new FormLayout();
name = new TextField("Name"); name = new TextField("Name");
cost = new TextField("Cost"); cost = new TextField("Cost");
category = new ComboBox<>("Category"); category = new ComboBox<>("Category");
category.setItems(categoryService.findAllByUserId(userId)); category.setItems(categoryService.findAll());
category.setItemLabelGenerator(Category::getName); category.setItemLabelGenerator(Category::getName);
description = new TextArea("Description"); description = new TextArea("Description");
periodUnit = new ComboBox<>("Period Unit"); periodUnit = new ComboBox<>("Period Unit");
periodUnit.setItems(PeriodUnit.values()); periodUnit.setItems(PeriodUnit.values());
periodInterval = new TextField("Period Interval"); periodInterval = new TextField("Period Interval");
payer = new ComboBox<>("Payer"); creditors = new MultiSelectComboBox<>("Creditors");
payer.setItems(people); creditors.setItems(personService.findAll());
payer.setItemLabelGenerator(person -> person.getFirstName() + " " + person.getLastName()); creditors.setItemLabelGenerator(Person::getFirstName);
beneficiary = new ComboBox<>("Beneficiary"); event = new ComboBox<>("Event");
beneficiary.setItems(people); event.setItems(eventService.findAll());
beneficiary.setItemLabelGenerator(person -> person.getFirstName() + " " + person.getLastName()); event.setItemLabelGenerator(Event::getName);
debtors = new MultiSelectComboBox<>("Debtors");
debtors.setItems(personService.findAll());
debtors.setItemLabelGenerator(Person::getFirstName);
date = new DatePicker("Date"); date = new DatePicker("Date");
// Horizontal layout for checkboxes // Horizontal layout for checkboxes
HorizontalLayout checkboxLayout = new HorizontalLayout(); HorizontalLayout checkboxLayout = new HorizontalLayout();
isPeriodic = new Checkbox("Is Periodic"); isPeriodic = new Checkbox("Is Periodic");
isPaid = new Checkbox("Paid"); isResolved = new Checkbox("Paid");
checkboxLayout.add(isPeriodic, isPaid); checkboxLayout.add(isPeriodic, isResolved);
formLayout.add(name, cost, category, description, checkboxLayout, periodUnit, periodInterval, date, payer, beneficiary); formLayout.add(name, cost, category, description, checkboxLayout, periodUnit, periodInterval, date, creditors, debtors, event);
editorDiv.add(formLayout); editorDiv.add(formLayout);
createButtonLayout(editorLayoutDiv); createButtonLayout(editorLayoutDiv);
@ -299,7 +278,7 @@ public class ExpensesView extends Div implements BeforeEnterObserver {
} }
private void refreshGrid() { private void refreshGrid() {
grid.setItems(this.expenseService.findAllOrderByDateDescending(userId)); grid.setItems(this.expenseService.findAllOrderByDateDescending());
grid.select(null); grid.select(null);
grid.getDataProvider().refreshAll(); grid.getDataProvider().refreshAll();
} }
@ -316,9 +295,15 @@ public class ExpensesView extends Div implements BeforeEnterObserver {
periodInterval.setVisible(isPeriodicChecked); periodInterval.setVisible(isPeriodicChecked);
} }
private void initializeComboBoxes() { private Span createBadge(Boolean isExpenseResolved) {
final var loggedInPerson = this.personFacade.getLoggedInPerson(); Span badge = new Span();
payer.setValue(loggedInPerson); if (Boolean.TRUE.equals(isExpenseResolved)) {
beneficiary.setValue(loggedInPerson); badge.setText("Resolved");
badge.getElement().getThemeList().add("badge success");
} else {
badge.setText("To be Resolved");
badge.getElement().getThemeList().add("badge error");
}
return badge;
} }
} }

View file

@ -36,7 +36,9 @@ public class LoginView extends VerticalLayout implements BeforeEnterObserver {
login.setI18n(i18n); login.setI18n(i18n);
// Add a listener for the Forgot password button // Add a listener for the Forgot password button
login.addForgotPasswordListener(event -> Notification.show("Tough shit, feature aint ready yet!", 3000, Notification.Position.BOTTOM_CENTER)); login.addForgotPasswordListener(event -> {
Notification.show("Tough shit, feature aint ready yet!", 3000, Notification.Position.BOTTOM_CENTER);
});
add(new H1("Munera"), new H2("An expense tracking application"), login); add(new H1("Munera"), new H2("An expense tracking application"), login);
} }

View file

@ -2,11 +2,8 @@ package com.application.munera.views.people;
import com.application.munera.data.Expense; import com.application.munera.data.Expense;
import com.application.munera.data.Person; import com.application.munera.data.Person;
import com.application.munera.data.User; import com.application.munera.services.ExpenseService;
import com.application.munera.facades.ExpenseFacade; import com.application.munera.services.PersonService;
import com.application.munera.facades.PersonFacade;
import com.application.munera.services.UserService;
import com.application.munera.services.ViewsService;
import com.application.munera.views.MainLayout; import com.application.munera.views.MainLayout;
import com.vaadin.flow.component.UI; import com.vaadin.flow.component.UI;
import com.vaadin.flow.component.button.Button; import com.vaadin.flow.component.button.Button;
@ -33,9 +30,9 @@ import com.vaadin.flow.router.BeforeEnterObserver;
import com.vaadin.flow.router.PageTitle; import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route; import com.vaadin.flow.router.Route;
import jakarta.annotation.security.PermitAll; import jakarta.annotation.security.PermitAll;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.orm.ObjectOptimisticLockingFailureException; import org.springframework.orm.ObjectOptimisticLockingFailureException;
import java.math.BigDecimal;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@ -57,23 +54,15 @@ public class PeopleView extends Div implements BeforeEnterObserver {
private final BeanValidationBinder<Person> binder; private final BeanValidationBinder<Person> binder;
private Person person; private Person person;
private final User loggedUser; private final PersonService personService;
private final Long userId; private final ExpenseService expenseService;
private final UserService userService;
private final PersonFacade personFacade;
private final ExpenseFacade expenseFacade;
private final ViewsService viewsService;
private TextField firstName; private TextField firstName;
private TextField lastName; private TextField lastName;
private EmailField email; private EmailField email;
public PeopleView(ViewsService viewsService, PersonFacade personFacade, ExpenseFacade expenseFacade, UserService userService) { public PeopleView(PersonService personService, ExpenseService expenseService) {
this.viewsService = viewsService; this.personService = personService;
this.personFacade = personFacade; this.expenseService = expenseService;
this.expenseFacade = expenseFacade;
this.userService = userService;
loggedUser = userService.getLoggedInUser();
userId = loggedUser.getId();
addClassNames("expenses-view"); addClassNames("expenses-view");
// Create UI // Create UI
@ -86,40 +75,13 @@ public class PeopleView extends Div implements BeforeEnterObserver {
grid.addThemeVariants(GridVariant.LUMO_NO_BORDER); grid.addThemeVariants(GridVariant.LUMO_NO_BORDER);
grid.addHierarchyColumn(this::getNodeName).setHeader("Name"); grid.addHierarchyColumn(this::getNodeName).setHeader("Name");
grid.addColumn(this::getNodeCost).setHeader("Balance").setSortable(true); grid.addColumn(this::getNodeCost).setHeader("Total Expenses Value").setSortable(true);
grid.addColumn(new ComponentRenderer<>(personEntry -> { grid.addColumn(new ComponentRenderer<>(persona -> {
if (personEntry instanceof Person person1) return this.viewsService.createPersonBadge(personFacade.calculateNetBalance(person1)); if (persona instanceof Person) return createPersonBadge(personService.calculateNetBalance((Person) persona));
else return this.viewsService.createExpenseBadge(((Expense) personEntry)); else return createExpenseBadge(((Expense) persona).getIsResolved());
})).setHeader("Balance Status"); })).setHeader("Balance Status");
grid.addColumn(new ComponentRenderer<>(persona -> { List<Person> people = (List<Person>) personService.findAll();
switch (persona) {
case Person person1 -> {
Button setDebtPaidButton = new Button("Set all debt as paid", event -> this.personFacade.setDebtPaid(person1, grid, userId));
setDebtPaidButton.addThemeVariants(ButtonVariant.LUMO_SMALL, ButtonVariant.LUMO_PRIMARY);
return setDebtPaidButton;
}
case Expense expense -> {
Button setExpensePaidButton = new Button("Set as paid", event -> this.expenseFacade.setExpensePaid(expense, grid, userId));
setExpensePaidButton.addThemeVariants(ButtonVariant.LUMO_SMALL);
if (Boolean.TRUE.equals((expense).getIsPaid())) setExpensePaidButton.setEnabled(false);
return setExpensePaidButton;
}
default -> {
return new Span();
}
}
}));
grid.addColumn(new ComponentRenderer<>(persona -> {
if (persona instanceof Person person1) {
Button setCreditPaidButton = new Button("Set all credit as paid", event -> this.personFacade.setCreditPaid(person1, grid, userId));
setCreditPaidButton.addThemeVariants(ButtonVariant.LUMO_SMALL, ButtonVariant.LUMO_PRIMARY);
return setCreditPaidButton;
} else return new Span();
}));
List<Person> people = personFacade.findAllExcludeLoggedUser(loggedUser);
this.setGridData(people); this.setGridData(people);
@ -133,14 +95,8 @@ public class PeopleView extends Div implements BeforeEnterObserver {
binder = new BeanValidationBinder<>(Person.class); binder = new BeanValidationBinder<>(Person.class);
// Bind fields. This is where you'd define e.g. validation rules // Bind fields. This is where you'd define e.g. validation rules
binder.bindInstanceFields(this);
binder.forField(firstName)
.asRequired("First Name is required")
.bind(Person::getFirstName, Person::setFirstName);
binder.forField(lastName) binder.bindInstanceFields(this);
.asRequired("Last Name is required")
.bind(Person::getLastName, Person::setLastName);
cancel.addClickListener(e -> { cancel.addClickListener(e -> {
clearForm(); clearForm();
@ -149,9 +105,11 @@ public class PeopleView extends Div implements BeforeEnterObserver {
save.addClickListener(e -> { save.addClickListener(e -> {
try { try {
if (this.person == null) this.person = new Person(); if (this.person == null) {
this.person = new Person();
}
binder.writeBean(this.person); binder.writeBean(this.person);
personFacade.update(this.person, userId); personService.update(this.person);
clearForm(); clearForm();
refreshGrid(); refreshGrid();
Notification.show("Data updated"); Notification.show("Data updated");
@ -162,14 +120,14 @@ public class PeopleView extends Div implements BeforeEnterObserver {
n.setPosition(Position.MIDDLE); n.setPosition(Position.MIDDLE);
n.addThemeVariants(NotificationVariant.LUMO_ERROR); n.addThemeVariants(NotificationVariant.LUMO_ERROR);
} catch (ValidationException validationException) { } catch (ValidationException validationException) {
Notification.show("Failed to update the person. Check again that all values are valid"); Notification.show("Failed to update the data. Check again that all values are valid");
} }
}); });
delete.addClickListener(e -> { delete.addClickListener(e -> {
try { try {
if (this.person == null) throw new IllegalStateException("The person is null!"); if (this.person == null) throw new RuntimeException("The person is null!"); //TODO: create proper exception
personFacade.delete(this.person.getId()); personService.delete(this.person.getId());
clearForm(); clearForm();
refreshGrid(); refreshGrid();
Notification.show("Data delete"); Notification.show("Data delete");
@ -179,24 +137,19 @@ public class PeopleView extends Div implements BeforeEnterObserver {
"Error updating the data. Somebody else has updated the record while you were making changes."); "Error updating the data. Somebody else has updated the record while you were making changes.");
n.setPosition(Position.MIDDLE); n.setPosition(Position.MIDDLE);
n.addThemeVariants(NotificationVariant.LUMO_ERROR); n.addThemeVariants(NotificationVariant.LUMO_ERROR);
} catch (DataIntegrityViolationException ex) {
Notification n = Notification.show(
"Cannot delete this person as it is associated with existing expenses.");
n.setPosition(Notification.Position.MIDDLE);
n.addThemeVariants(NotificationVariant.LUMO_ERROR);
} }
}); });
} }
private String getNodeName(Object node) { private String getNodeName(Object node) {
if (node instanceof Person person1) return (person1).getFirstName() + " " + (person1).getLastName(); if (node instanceof Person) return ((Person) node).getFirstName() + " " + ((Person) node).getLastName();
else if (node instanceof Expense expense1) return (expense1).getName(); else if (node instanceof Expense) return ((Expense) node).getName();
return ""; return "";
} }
private String getNodeCost(Object node) { private String getNodeCost(Object node) {
if (node instanceof Person person1) return this.personFacade.calculateNetBalance(person1) + ""; if (node instanceof Person) return this.personService.calculateNetBalance((Person) node).toString() + "";
else if (node instanceof Expense expense1) return (expense1).getCost().toString() + ""; else if (node instanceof Expense) return ((Expense) node).getCost().toString() + "";
return ""; return "";
} }
@ -204,13 +157,15 @@ public class PeopleView extends Div implements BeforeEnterObserver {
public void beforeEnter(BeforeEnterEvent event) { public void beforeEnter(BeforeEnterEvent event) {
Optional<Long> personId = event.getRouteParameters().get(PERSON_ID).map(Long::parseLong); Optional<Long> personId = event.getRouteParameters().get(PERSON_ID).map(Long::parseLong);
if (personId.isPresent()) { if (personId.isPresent()) {
Optional<Person> personFromBackend = personFacade.findById(personId.get()); Optional<Person> personFromBackend = personService.get(personId.get());
if (personFromBackend.isPresent()) populateForm(personFromBackend.get()); if (personFromBackend.isPresent()) populateForm(personFromBackend.get());
else { else {
Notification.show( Notification.show(
String.format("The requested person was not found, ID = %s", personId.get()), 3000, String.format("The requested person was not found, ID = %s", personId.get()), 3000,
Position.BOTTOM_START); Position.BOTTOM_START);
refreshGrid(); // when a row is selected but the data is no longer available refresh grid // when a row is selected but the data is no longer available,
// refresh grid
refreshGrid();
event.forwardTo(PeopleView.class); event.forwardTo(PeopleView.class);
} }
} }
@ -219,6 +174,7 @@ public class PeopleView extends Div implements BeforeEnterObserver {
private void createEditorLayout(SplitLayout splitLayout) { private void createEditorLayout(SplitLayout splitLayout) {
Div editorLayoutDiv = new Div(); Div editorLayoutDiv = new Div();
editorLayoutDiv.setClassName("editor-layout"); editorLayoutDiv.setClassName("editor-layout");
Div editorDiv = new Div(); Div editorDiv = new Div();
editorDiv.setClassName("editor"); editorDiv.setClassName("editor");
editorLayoutDiv.add(editorDiv); editorLayoutDiv.add(editorDiv);
@ -270,13 +226,40 @@ public class PeopleView extends Div implements BeforeEnterObserver {
} }
private Span createPersonBadge(BigDecimal netBalance) {
Span badge = new Span();
if (netBalance.compareTo(BigDecimal.ZERO) < 0) {
badge.setText("Credit");
badge.getElement().getThemeList().add("badge success");
} else if (netBalance.compareTo(BigDecimal.ZERO) > 0) {
badge.setText("Debit");
badge.getElement().getThemeList().add("badge error");
} else {
badge.setText("Clear");
badge.getElement().getThemeList().add("badge contrast");
}
return badge;
}
private Span createExpenseBadge(Boolean isExpenseResolved) {
Span badge = new Span();
if (Boolean.TRUE.equals(isExpenseResolved)) {
badge.setText("Resolved");
badge.getElement().getThemeList().add("badge success");
} else {
badge.setText("To be Resolved");
badge.getElement().getThemeList().add("badge error");
}
return badge;
}
public void setGridData(List<Person> people) { public void setGridData(List<Person> people) {
for (Person person : people) { for (Person person : people) {
// Add the person as a root item // Add the person as a root item
grid.getTreeData().addItem(null, person); grid.getTreeData().addItem(null, person);
// Fetch expenses for the current person // Fetch expenses for the current person
List<Expense> expenses = expenseFacade.findExpensesByPerson(person); List<Expense> expenses = expenseService.findExpenseByUser(person);
// Add each expense as a child item under the person // Add each expense as a child item under the person
for (Expense expense : expenses) grid.getTreeData().addItem(person, expense); for (Expense expense : expenses) grid.getTreeData().addItem(person, expense);

View file

@ -1,119 +0,0 @@
package com.application.munera.views.settings;
import com.application.munera.data.User;
import com.application.munera.services.UserService;
import com.application.munera.views.MainLayout;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.dependency.Uses;
import com.vaadin.flow.component.formlayout.FormLayout;
import com.vaadin.flow.component.icon.Icon;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.notification.NotificationVariant;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.EmailField;
import com.vaadin.flow.component.textfield.PasswordField;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.data.binder.BeanValidationBinder;
import com.vaadin.flow.data.binder.ValidationException;
import com.vaadin.flow.router.BeforeEnterEvent;
import com.vaadin.flow.router.BeforeEnterObserver;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import jakarta.annotation.security.PermitAll;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
@PageTitle("Settings")
@PermitAll
@Uses(Icon.class)
@Route(value = "settings", layout = MainLayout.class)
public class SettingsView extends VerticalLayout implements BeforeEnterObserver {
private final UserService userService;
private TextField firstName;
private TextField lastName;
private PasswordField password;
private TextField monthlyIncome;
private EmailField email;
private final BeanValidationBinder<User> binder;
private final Button save = new Button("Save");
private final User loggedInUser;
@Autowired
public SettingsView(UserService userService) {
this.userService = userService;
createForm();
loggedInUser = userService.getLoggedInUser();
binder = new BeanValidationBinder<>(User.class);
// Bind fields. This is where you'd define e.g. validation rules
binder.bindInstanceFields(this);
binder.forField(firstName)
.asRequired("First name is required")
.bind(User::getFirstName, User::setFirstName);
binder.forField(lastName)
.asRequired("Last name is required")
.bind(User::getLastName, User::setLastName);
binder.forField(password)
.asRequired("Password is required")
.bind(User::getPassword, User::setPassword);
save.addClickListener(e -> {
try {
binder.writeBean(this.loggedInUser);
this.saveUserData();
Notification.show("User details updated successfully");
} catch (ObjectOptimisticLockingFailureException exception) {
Notification n = Notification.show(
"Error updating the data. Somebody else has updated the record while you were making changes.");
n.setPosition(Notification.Position.MIDDLE);
n.addThemeVariants(NotificationVariant.LUMO_ERROR);
} catch (ValidationException validationException) {
Notification.show("Failed to update the user. Check again that all values are valid");
}
});
}
private void createForm() {
FormLayout formLayout = new FormLayout();
firstName = new TextField("First Name");
lastName = new TextField("Last Name");
password = new PasswordField("Password");
email = new EmailField("Email");
monthlyIncome = new TextField("Monthly Income");
formLayout.add(firstName, lastName, password, email, monthlyIncome);
add(formLayout, this.save);
}
private void saveUserData() {
loggedInUser.setFirstName(firstName.getValue());
loggedInUser.setLastName(lastName.getValue());
loggedInUser.setEmail(email.getValue());
loggedInUser.setPassword(password.getValue());
// TODO: implement
String monthlyIncome = this.monthlyIncome.getValue();
userService.saveOrUpdateUserAndConnectedPerson(loggedInUser);
}
@Override
public void beforeEnter(BeforeEnterEvent event) {
final var getLoggedInUser = userService.getLoggedInUser();
firstName.setValue(getLoggedInUser.getFirstName());
lastName.setValue(getLoggedInUser.getLastName());
password.setValue(getLoggedInUser.getPassword());
email.setValue(getLoggedInUser.getEmail());
monthlyIncome.setValue("");
}
}

View file

@ -1,238 +0,0 @@
package com.application.munera.views.users;
import com.application.munera.data.enums.Role;
import com.application.munera.data.User;
import com.application.munera.services.UserService;
import com.application.munera.views.MainLayout;
import com.vaadin.flow.component.UI;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.combobox.MultiSelectComboBox;
import com.vaadin.flow.component.dependency.Uses;
import com.vaadin.flow.component.formlayout.FormLayout;
import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.grid.GridVariant;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.icon.Icon;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.notification.NotificationVariant;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.splitlayout.SplitLayout;
import com.vaadin.flow.component.textfield.EmailField;
import com.vaadin.flow.component.textfield.PasswordField;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.data.binder.BeanValidationBinder;
import com.vaadin.flow.data.binder.ValidationException;
import com.vaadin.flow.data.validator.EmailValidator;
import com.vaadin.flow.router.BeforeEnterEvent;
import com.vaadin.flow.router.BeforeEnterObserver;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import jakarta.annotation.security.RolesAllowed;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.Optional;
@PageTitle("Users")
@RolesAllowed("ADMIN")
@Route(value = "users/:userID?/:action?(edit)", layout = MainLayout.class)
@Uses(Icon.class)
public class UsersView extends Div implements BeforeEnterObserver {
private static final String USER_ID = "userID";
private static final String USER_EDIT_ROUTE_TEMPLATE = "users/%s/edit";
private final Grid<User> grid = new Grid<>();
private final Button cancel = new Button("Cancel");
private final Button save = new Button("Save");
private final Button delete = new Button("Delete");
private final BeanValidationBinder<User> binder;
private User user;
private final UserService userService;
private TextField firstName;
private TextField lastName;
private TextField username;
private PasswordField password;
private EmailField email;
private MultiSelectComboBox<Role> roles; // Updated to MultiSelectComboBox
public UsersView(UserService userService) {
this.userService = userService;
addClassNames("expenses-view");
// Create UI
SplitLayout splitLayout = new SplitLayout();
createGridLayout(splitLayout);
createEditorLayout(splitLayout);
add(splitLayout);
grid.addColumn(User::getFirstName).setHeader("First name").setSortable(true);
grid.addColumn(User::getLastName).setHeader("Last name").setSortable(true);
grid.addColumn(User::getUsername).setHeader("Username").setSortable(true);
grid.addColumn(User::getEmail).setHeader("Email").setSortable(true);
grid.addColumn(User::getRoles).setHeader("Roles").setSortable(true);
grid.getColumns().forEach(col -> col.setAutoWidth(true));
grid.setItems(this.userService.findAll());
grid.addThemeVariants(GridVariant.LUMO_NO_BORDER);
// when a row is selected or deselected, populate form
grid.asSingleSelect().addValueChangeListener(event -> {
if (event.getValue() != null) UI.getCurrent().navigate(String.format(USER_EDIT_ROUTE_TEMPLATE, event.getValue().getId()));
else {
clearForm();
UI.getCurrent().navigate(UsersView.class);
}
});
binder = new BeanValidationBinder<>(User.class);
// Bind fields with validation rules
binder.forField(firstName)
.asRequired("First name is required")
.bind(User::getFirstName, User::setFirstName);
binder.forField(lastName)
.asRequired("Last name is required")
.bind(User::getLastName, User::setLastName);
binder.forField(username)
.asRequired("Username is required")
.bind(User::getUsername, User::setUsername);
binder.forField(password)
.asRequired("Password is required")
.bind(User::getPassword, User::setPassword);
binder.forField(email)
.withValidator(
new EmailValidator("Please enter a valid email address"))
.bind(User::getEmail, User::setEmail);
binder.forField(roles)
.bind(user1 -> new HashSet<>(user.getRoleList()), // Getter to convert roles string to list
(user1, roles) -> user.setRoleList(new ArrayList<>(roles))); // Setter to convert list back to string
// Button listeners
cancel.addClickListener(e -> {
clearForm();
refreshGrid();
});
save.addClickListener(e -> {
try {
if (this.user == null) this.user = new User();
binder.writeBean(this.user);
this.userService.saveOrUpdateUserAndConnectedPerson(this.user);
clearForm();
refreshGrid();
Notification.show("Data updated");
UI.getCurrent().navigate(UsersView.class);
} catch (ObjectOptimisticLockingFailureException exception) {
Notification n = Notification.show(
"Error updating the data. Somebody else has updated the record while you were making changes.");
n.setPosition(Notification.Position.MIDDLE);
n.addThemeVariants(NotificationVariant.LUMO_ERROR);
} catch (ValidationException validationException) {
Notification.show("Failed to update the user. Check again that all values are valid");
}
});
delete.addClickListener(e -> {
try {
if (this.user == null) throw new IllegalStateException("The user is null!");
userService.delete(this.user);
clearForm();
refreshGrid();
Notification.show("Data delete");
UI.getCurrent().navigate(UsersView.class);
} catch (ObjectOptimisticLockingFailureException exception) {
Notification n = Notification.show(
"Error updating the data. Somebody else has updated the record while you were making changes.");
n.setPosition(Notification.Position.MIDDLE);
n.addThemeVariants(NotificationVariant.LUMO_ERROR);
}
});
}
@Override
public void beforeEnter(BeforeEnterEvent event) {
Optional<Long> userId = event.getRouteParameters().get(USER_ID).map(Long::parseLong);
if (userId.isPresent()) {
Optional<User> userFromBackend = this.userService.findById(userId.get());
if (userFromBackend.isPresent()) populateForm(userFromBackend.get());
else {
Notification.show(
String.format("The requested user was not found, ID = %s", userId.get()), 3000,
Notification.Position.BOTTOM_START);
refreshGrid(); // when a row is selected but the data is no longer available refresh grid
event.forwardTo(UsersView.class);
}
}
}
private void createEditorLayout(SplitLayout splitLayout) {
Div editorLayoutDiv = new Div();
editorLayoutDiv.setClassName("editor-layout");
Div editorDiv = new Div();
editorDiv.setClassName("editor");
editorLayoutDiv.add(editorDiv);
FormLayout formLayout = new FormLayout();
firstName = new TextField("First Name");
lastName = new TextField("Last Name");
username = new TextField("Username");
password = new PasswordField("Password");
email = new EmailField("Email");
roles = new MultiSelectComboBox<>("Roles");
roles.setItems(Role.values()); // Set the enum values as options
roles.setItemLabelGenerator(Role::getRoleName); // Define how to display roles
// We set the maximum parallel columns to 1
formLayout.setResponsiveSteps(new FormLayout.ResponsiveStep("0", 1));
formLayout.add(firstName, lastName, username, password, email, roles);
editorDiv.add(formLayout);
createButtonLayout(editorLayoutDiv);
splitLayout.addToSecondary(editorLayoutDiv);
}
private void createButtonLayout(Div editorLayoutDiv) {
HorizontalLayout buttonLayout = new HorizontalLayout();
buttonLayout.setClassName("button-layout");
cancel.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
save.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
delete.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
buttonLayout.add(save, delete, cancel);
editorLayoutDiv.add(buttonLayout);
}
private void createGridLayout(SplitLayout splitLayout) {
Div wrapper = new Div();
wrapper.setClassName("grid-wrapper");
splitLayout.addToPrimary(wrapper);
wrapper.add(grid);
}
private void refreshGrid() {
grid.select(null);
grid.getDataProvider().refreshAll();
}
private void clearForm() {
populateForm(null);
}
private void populateForm(User value) {
this.user = value;
binder.readBean(this.user);
}
}

View file

@ -2,25 +2,12 @@ server.port=${PORT:8080}
logging.level.org.atmosphere = warn logging.level.org.atmosphere = warn
spring.mustache.check-template-location = false spring.mustache.check-template-location = false
# Activate the default profile
spring.profiles.active=dev
# Launch the default browser when starting the application in development mode # Launch the default browser when starting the application in development mode
#vaadin.launch-browser=true #vaadin.launch-browser=true
# PostgreSQL configuration. # PostgreSQL configuration.
spring.datasource.url = ${DB_ADDRESS}/${DB_NAME} spring.datasource.url = jdbc:postgresql://localhost:5432/munera_vaadin
spring.datasource.username = ${DB_USER} spring.datasource.username = postgres
spring.datasource.password = ${DB_PASSWORD} spring.datasource.password =
# Default user
admin.username=${ADMIN_USERNAME}
admin.password=${ADMIN_PASSWORD}
admin.first_name=${ADMIN_FIRST_NAME}
admin.last_name=${ADMIN_LAST_NAME}
admin.roles=${ADMIN_ROLES}
admin.email=${ADMIN_EMAIL}
spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect
spring.jpa.hibernate.ddl-auto = update spring.jpa.hibernate.ddl-auto = update
# To improve the performance during development. # To improve the performance during development.
# For more information https://vaadin.com/docs/latest/integrations/spring/configuration#special-configuration-parameters # For more information https://vaadin.com/docs/latest/integrations/spring/configuration#special-configuration-parameters

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

View file

@ -1,93 +0,0 @@
package com.application.munera.services;
import com.application.munera.data.Expense;
import com.application.munera.data.Person;
import com.application.munera.repositories.ExpenseRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import java.time.Year;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class ExpenseServiceTest {
@Mock
private ExpenseRepository expenseRepository;
@Mock
private Person loggedInPerson;
@InjectMocks
private ExpenseService expenseService;
private Year year;
@BeforeEach
public void setUp() {
year = Year.of(2023);
when(loggedInPerson.getId()).thenReturn(1L);
}
@Test
void testFetchExpensesForDashboard_NoExpenses() {
when(expenseRepository.findExpensesByPayerAndBeneficiaryAndYear(loggedInPerson.getId(), year.getValue()))
.thenReturn(new ArrayList<>());
when(expenseRepository.findExpensesByBeneficiaryAndYear(loggedInPerson.getId(), year.getValue()))
.thenReturn(new ArrayList<>());
when(expenseRepository.findExpensesByPayerAndYear(loggedInPerson.getId(), year.getValue()))
.thenReturn(new ArrayList<>());
List<Expense> result = expenseService.fetchExpensesForDashboard(loggedInPerson, year);
assertEquals(0, result.size(), "Expected no expenses to be fetched");
}
@Test
@Disabled("will need to become integration test")
void testFetchExpensesForDashboard_WithSelfExpenses() {
Expense selfExpense = new Expense(); // Create a dummy Expense object
List<Expense> bothExpenses = List.of(selfExpense);
when(expenseRepository.findExpensesByPayerAndBeneficiaryAndYear(loggedInPerson.getId(), year.getValue()))
.thenReturn(bothExpenses);
when(expenseRepository.findExpensesByBeneficiaryAndYear(loggedInPerson.getId(), year.getValue()))
.thenReturn(new ArrayList<>());
when(expenseRepository.findExpensesByPayerAndYear(loggedInPerson.getId(), year.getValue()))
.thenReturn(new ArrayList<>());
List<Expense> result = expenseService.fetchExpensesForDashboard(loggedInPerson, year);
assertEquals(1, result.size(), "Expected one self-expense to be fetched");
assertEquals(selfExpense, result.get(0), "Expected the self-expense to match");
}
@Test
@Disabled("will need to become integration test")
void testFetchExpensesForDashboard_WithUnpaidExpenses() {
Expense unpaidExpense = new Expense();
unpaidExpense.setIsPaid(false);
when(expenseRepository.findExpensesByPayerAndBeneficiaryAndYear(loggedInPerson.getId(), year.getValue()))
.thenReturn(new ArrayList<>());
when(expenseRepository.findExpensesByBeneficiaryAndYear(loggedInPerson.getId(), year.getValue()))
.thenReturn(new ArrayList<>());
when(expenseRepository.findExpensesByPayerAndYear(loggedInPerson.getId(), year.getValue()))
.thenReturn(List.of(unpaidExpense));
List<Expense> result = expenseService.fetchExpensesForDashboard(loggedInPerson, year);
assertEquals(1, result.size(), "Expected one unpaid expense to be fetched");
assertEquals(unpaidExpense, result.getFirst(), "Expected the unpaid expense to match");
}
}

View file

@ -1,148 +0,0 @@
package com.application.munera.services;
import com.application.munera.data.Expense;
import com.application.munera.data.Person;
import com.application.munera.repositories.ExpenseRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import java.math.BigDecimal;
import java.util.Collections;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class PersonServiceTest {
@Mock
private ExpenseRepository expenseRepository;
@InjectMocks
private PersonService personService;
@Test
void calculateDeb_whenExpensesAreMixed() {
Person person = new Person();
person.setLastName("first");
person.setFirstName("person");
person.setId(1L);
Person person2 = new Person();
person2.setLastName("second");
person2.setFirstName("person");
Expense expense1 = mock(Expense.class);
when(expense1.getPayer()).thenReturn(person);
when(expense1.getBeneficiary()).thenReturn(person2);
when(expense1.getCost()).thenReturn(new BigDecimal("110.00"));
when(expense1.getIsPaid()).thenReturn(false);
Expense expense2 = mock(Expense.class);
when(expense2.getPayer()).thenReturn(person);
when(expense2.getBeneficiary()).thenReturn(person);
when(expense2.getCost()).thenReturn(new BigDecimal("50.00"));
when(expense2.getIsPaid()).thenReturn(false);
Expense expense3 = mock(Expense.class);
when(expense3.getPayer()).thenReturn(person);
when(expense3.getBeneficiary()).thenReturn(person);
when(expense3.getCost()).thenReturn(new BigDecimal("50.00"));
when(expense3.getIsPaid()).thenReturn(true);
when(expenseRepository.findExpensesByPayer(person.getId())).thenReturn(List.of(expense1, expense2, expense3));
BigDecimal totalDebt = personService.calculateDebt(person);
assertEquals(new BigDecimal("110.00"), totalDebt);
}
@Test
void calculateDebt_NoExpenses() {
// Arrange
Person person = new Person();
person.setId(1L);
when(expenseRepository.findExpensesByPayer(person.getId())).thenReturn(Collections.emptyList());
// Act
BigDecimal totalDebt = personService.calculateDebt(person);
// Assert
assertEquals(BigDecimal.ZERO, totalDebt);
}
@Test
void calculateDebt_AllExpensesPaid() {
// Arrange
Person person = new Person();
person.setId(1L);
Expense expense1 = mock(Expense.class);
when(expense1.getPayer()).thenReturn(person);
when(expense1.getBeneficiary()).thenReturn(new Person());
when(expense1.getCost()).thenReturn(new BigDecimal("100.00"));
when(expense1.getIsPaid()).thenReturn(true);
Expense expense2 = mock(Expense.class);
when(expense2.getPayer()).thenReturn(person);
when(expense2.getBeneficiary()).thenReturn(new Person());
when(expense2.getCost()).thenReturn(new BigDecimal("50.00"));
when(expense2.getIsPaid()).thenReturn(true);
when(expenseRepository.findExpensesByPayer(person.getId())).thenReturn(List.of(expense1, expense2));
// Act
BigDecimal totalDebt = personService.calculateDebt(person);
// Assert
assertEquals(BigDecimal.ZERO, totalDebt);
}
@Test
void calculateDebt_ExpensesWithSamePayerAndBeneficiary() {
// Arrange
Person person = new Person();
person.setId(1L);
Expense expense1 = mock(Expense.class);
when(expense1.getPayer()).thenReturn(person);
when(expense1.getBeneficiary()).thenReturn(person); // Same person as payer
when(expense1.getCost()).thenReturn(new BigDecimal("100.00"));
when(expense1.getIsPaid()).thenReturn(false);
when(expenseRepository.findExpensesByPayer(person.getId())).thenReturn(List.of(expense1));
// Act
BigDecimal totalDebt = personService.calculateDebt(person);
// Assert
assertEquals(BigDecimal.ZERO, totalDebt);
}
@Test
void calculateDebt_ExpensesWithNullAttributes() {
// Arrange
Person person = new Person();
person.setId(1L);
Expense expense1 = mock(Expense.class);
when(expense1.getPayer()).thenReturn(person);
when(expense1.getBeneficiary()).thenReturn(new Person());
when(expense1.getCost()).thenReturn(null); // Null cost
when(expense1.getIsPaid()).thenReturn(false);
when(expenseRepository.findExpensesByPayer(person.getId())).thenReturn(List.of(expense1));
// Act
BigDecimal totalDebt = personService.calculateDebt(person);
// Assert
assertEquals(BigDecimal.ZERO, totalDebt);
}
}

View file

@ -1,39 +0,0 @@
// This TypeScript configuration file is generated by vaadin-maven-plugin.
// This is needed for TypeScript compiler to compile your TypeScript code in the project.
// It is recommended to commit this file to the VCS.
// You might want to change the configurations to fit your preferences
// For more information about the configurations, please refer to http://www.typescriptlang.org/docs/handbook/tsconfig-json.html
{
"_version": "9",
"compilerOptions": {
"sourceMap": true,
"jsx": "react-jsx",
"inlineSources": true,
"module": "esNext",
"target": "es2020",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noImplicitReturns": true,
"noImplicitAny": true,
"noImplicitThis": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"experimentalDecorators": true,
"useDefineForClassFields": false,
"baseUrl": "frontend",
"paths": {
"@vaadin/flow-frontend": ["generated/jar-resources"],
"@vaadin/flow-frontend/*": ["generated/jar-resources/*"],
"Frontend/*": ["*"]
}
},
"include": [
"frontend/**/*",
"types.d.ts"
],
"exclude": [
"frontend/generated/jar-resources/**"
]
}

10
types.d.ts vendored
View file

@ -1,10 +0,0 @@
// This TypeScript modules definition file is generated by vaadin-maven-plugin.
// You can not directly import your different static files into TypeScript,
// This is needed for TypeScript compiler to declare and export as a TypeScript module.
// It is recommended to commit this file to the VCS.
// You might want to change the configurations to fit your preferences
declare module '*.css' {
import { CSSResultGroup } from 'lit';
const content: CSSResultGroup;
export default content;
}

View file

@ -1,9 +0,0 @@
import { UserConfigFn } from 'vite';
import { overrideVaadinConfig } from './vite.generated';
const customConfig: UserConfigFn = (env) => ({
// Here you can add custom Vite parameters
// https://vitejs.dev/config/
});
export default overrideVaadinConfig(customConfig);