Index: .gitignore
===================================================================
--- .gitignore	(revision 5ee2d79a544d8f29386e3bbc597e35433bb9b89a)
+++ .gitignore	(revision f41a3f0b4f9e00e589236ea75a868b2e4ef2286b)
@@ -1,36 +1,27 @@
-# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+# See https://www.dartlang.org/guides/libraries/private-files
 
-# dependencies
-/node_modules
-/.pnp
-.pnp.js
+# Files and directories created by pub
+.dart_tool/
+.packages
+build/
+# If you're building an application, you may want to check-in your pubspec.lock
+pubspec.lock
 
-# testing
-/coverage
+# Directory created by dartdoc
+# If you don't generate documentation locally you can remove this line.
+doc/api/
 
-# next.js
-/.next/
-/out/
+# dotenv environment variables file
+.env*
 
-# production
-/build
+# Avoid committing generated Javascript files:
+*.dart.js
+*.info.json      # Produced by the --dump-info flag.
+*.js             # When generated by dart2js. Don't specify *.js if your
+                 # project includes source files written in JavaScript.
+*.js_
+*.js.deps
+*.js.map
 
-# misc
-.DS_Store
-*.pem
-
-# debug
-npm-debug.log*
-yarn-debug.log*
-yarn-error.log*
-
-# local env files
-.env*.local
-.env
-
-# vercel
-.vercel
-
-# typescript
-*.tsbuildinfo
-next-env.d.ts
+.flutter-plugins
+.flutter-plugins-dependencies
Index: LICENSE
===================================================================
--- LICENSE	(revision f41a3f0b4f9e00e589236ea75a868b2e4ef2286b)
+++ LICENSE	(revision f41a3f0b4f9e00e589236ea75a868b2e4ef2286b)
@@ -0,0 +1,674 @@
+                    GNU GENERAL PUBLIC LICENSE
+                       Version 3, 29 June 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 General Public License is a free, copyleft license for
+software and other kinds of works.
+
+  The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works.  By contrast,
+the GNU General Public License is 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.  We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors.  You can apply it to
+your programs, too.
+
+  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.
+
+  To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights.  Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+  For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received.  You must make sure that they, too, receive
+or can get the source code.  And you must show them these terms so they
+know their rights.
+
+  Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+  For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software.  For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+  Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so.  This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software.  The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable.  Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products.  If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+  Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary.  To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+  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 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. Use with the GNU Affero General Public License.
+
+  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 Affero 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 special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+  14. Revised Versions of this License.
+
+  The Free Software Foundation may publish revised and/or new versions of
+the GNU 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 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 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 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 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 General Public License for more details.
+
+    You should have received a copy of the GNU 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 the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+    <program>  Copyright (C) <year>  <name of author>
+    This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+    This is free software, and you are welcome to redistribute it
+    under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License.  Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+  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 GPL, see
+<https://www.gnu.org/licenses/>.
+
+  The GNU General Public License does not permit incorporating your program
+into proprietary programs.  If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library.  If this is what you want to do, use the GNU Lesser General
+Public License instead of this License.  But first, please read
+<https://www.gnu.org/licenses/why-not-lgpl.html>.
Index: CENSE.md
===================================================================
--- LICENSE.md	(revision 5ee2d79a544d8f29386e3bbc597e35433bb9b89a)
+++ 	(revision )
@@ -1,129 +1,0 @@
-# PolyForm Perimeter License 1.0.0
-
-<https://polyformproject.org/licenses/perimeter/1.0.0>
-
-## Acceptance
-
-In order to get any license under these terms, you must agree
-to them as both strict obligations and conditions to all
-your licenses.
-
-## Copyright License
-
-The licensor grants you a copyright license for the
-software to do everything you might do with the software
-that would otherwise infringe the licensor's copyright
-in it for any permitted purpose.  However, you may
-only distribute the software according to [Distribution
-License](#distribution-license) and make changes or new works
-based on the software according to [Changes and New Works
-License](#changes-and-new-works-license).
-
-## Distribution License
-
-The licensor grants you an additional copyright license
-to distribute copies of the software.  Your license
-to distribute covers distributing the software with
-changes and new works permitted by [Changes and New Works
-License](#changes-and-new-works-license).
-
-## Notices
-
-You must ensure that anyone who gets a copy of any part of
-the software from you also gets a copy of these terms or the
-URL for them above, as well as copies of any plain-text lines
-beginning with `Required Notice:` that the licensor provided
-with the software.  For example:
-
-> Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
-
-## Changes and New Works License
-
-The licensor grants you an additional copyright license to
-make changes and new works based on the software for any
-permitted purpose.
-
-## Patent License
-
-The licensor grants you a patent license for the software that
-covers patent claims the licensor can license, or becomes able
-to license, that you would infringe by using the software.
-
-## Noncompete
-
-Any purpose is a permitted purpose, except for providing to
-others any product that competes with the software.
-
-## Competition
-
-If you use this software to market a product as a substitute
-for the functionality or value of the software, it competes
-with the software. A product may compete regardless how it is
-designed or deployed. For example, a product may compete even
-if it provides its functionality via any kind of interface
-(including services, libraries or plug-ins), even if it is
-ported to a different platforms or programming languages,
-and even if it is provided free of charge.
-
-## Fair Use
-
-You may have "fair use" rights for the software under the
-law. These terms do not limit them.
-
-## No Other Rights
-
-These terms do not allow you to sublicense or transfer any of
-your licenses to anyone else, or prevent the licensor from
-granting licenses to anyone else.  These terms do not imply
-any other licenses.
-
-## Patent Defense
-
-If you make any written claim that the software infringes or
-contributes to infringement of any patent, your patent license
-for the software granted under these terms ends immediately. If
-your company makes such a claim, your patent license ends
-immediately for work on behalf of your company.
-
-## Violations
-
-The first time you are notified in writing that you have
-violated any of these terms, or done anything with the software
-not covered by your licenses, your licenses can nonetheless
-continue if you come into full compliance with these terms,
-and take practical steps to correct past violations, within
-32 days of receiving notice.  Otherwise, all your licenses
-end immediately.
-
-## No Liability
-
-***As far as the law allows, the software comes as is, without
-any warranty or condition, and the licensor will not be liable
-to you for any damages arising out of these terms or the use
-or nature of the software, under any kind of legal claim.***
-
-## Definitions
-
-The **licensor** is the individual or entity offering these
-terms, and the **software** is the software the licensor makes
-available under these terms.
-
-A **product** can be a good or service, or a combination
-of them.
-
-**You** refers to the individual or entity agreeing to these
-terms.
-
-**Your company** is any legal entity, sole proprietorship,
-or other kind of organization that you work for, plus all
-organizations that have control over, are under the control of,
-or are under common control with that organization.  **Control**
-means ownership of substantially all the assets of an entity,
-or the power to direct its management and policies by vote,
-contract, or otherwise.  Control can be direct or indirect.
-
-**Your licenses** are all the licenses granted to you for the
-software under these terms.
-
-**Use** means anything you do with the software requiring one
-of your licenses.
Index: README.md
===================================================================
--- README.md	(revision 5ee2d79a544d8f29386e3bbc597e35433bb9b89a)
+++ README.md	(revision f41a3f0b4f9e00e589236ea75a868b2e4ef2286b)
@@ -1,2 +1,2 @@
 # FEiN
-Mobile-first web-application for personal finance tracking
+Mobile application for personal finance tracking
Index: p/(app)/add/actions.ts
===================================================================
--- app/(app)/add/actions.ts	(revision 5ee2d79a544d8f29386e3bbc597e35433bb9b89a)
+++ 	(revision )
@@ -1,302 +1,0 @@
-'use server';
-
-import { auth } from '@/auth';
-import { redirect } from 'next/navigation';
-import { sql } from '@/app/lib/db';
-import { revalidatePath } from 'next/cache';
-
-// ─── Shared helpers ────────────────────────────────────────────
-
-export type ActionState = {
-    error?: string;
-    success?: string;
-} | undefined;
-
-async function requireUserId(): Promise<number> {
-    const session = await auth();
-    if (!session?.user?.id) {
-        redirect('/login');
-    }
-    const userId = Number(session.user.id);
-    if (!Number.isInteger(userId)) {
-        redirect('/login');
-    }
-    return userId;
-}
-
-// ─── Add Transaction Account ───────────────────────────────────
-
-export async function addTransactionAccount(
-    _prev: ActionState,
-    formData: FormData,
-): Promise<ActionState> {
-    const userId = await requireUserId();
-
-    const name = String(formData.get('name') ?? '').trim();
-    const balanceRaw = String(formData.get('balance') ?? '0').trim();
-
-    if (!name) {
-        return { error: 'Account name is required.' };
-    }
-
-    const balance = Number(balanceRaw);
-    if (Number.isNaN(balance)) {
-        return { error: 'Balance must be a number.' };
-    }
-
-    try {
-        await sql`
-            INSERT INTO transaction_account (account_name, balance, user_id)
-            VALUES (${name}, ${balance}, ${userId})
-        `;
-    } catch {
-        return { error: 'Failed to create account.' };
-    }
-
-    revalidatePath('/dashboard');
-    revalidatePath('/add');
-    return { success: `Account "${name}" created.` };
-}
-
-// ─── Add Tag ───────────────────────────────────────────────────
-
-export async function addTag(
-    _prev: ActionState,
-    formData: FormData,
-): Promise<ActionState> {
-    await requireUserId(); // auth guard only
-
-    const name = String(formData.get('name') ?? '').trim().toLowerCase();
-
-    if (!name) {
-        return { error: 'Tag name is required.' };
-    }
-    if (name.startsWith('__note:')) {
-        return { error: 'Reserved tag prefix.' };
-    }
-
-    try {
-        // eslint-disable-next-line @typescript-eslint/no-explicit-any
-        await sql.begin(async (tx: any) => {
-            // Check duplicate (case-insensitive)
-            const existing = await tx`
-                SELECT tag_id FROM tag WHERE LOWER(tag_name) = ${name}
-            `;
-            if (existing.length > 0) {
-                throw new Error(`Tag "${name}" already exists.`);
-            }
-
-            await tx`INSERT INTO tag (tag_name) VALUES (${name})`;
-        });
-    } catch (e: any) {
-        if (e instanceof Error && e.message.includes('already exists')) {
-            return { error: e.message };
-        }
-        return { error: 'Failed to create tag.' };
-    }
-
-    revalidatePath('/add');
-    return { success: `Tag "${name}" created.` };
-}
-
-// ─── Add Transaction ───────────────────────────────────────────
-
-type BreakdownInput = {
-    type: 'from' | 'to';
-    accountId: number;
-    amount: number;
-};
-
-export async function addTransaction(
-    _prev: ActionState,
-    formData: FormData,
-): Promise<ActionState> {
-    const userId = await requireUserId();
-
-    const name = String(formData.get('name') ?? '').trim();
-    const date = String(formData.get('date') ?? '').trim();
-    const amountRaw = String(formData.get('amount') ?? '').trim();
-    const tagsJson = String(formData.get('tags') ?? '[]');
-    const pendingTag = String(formData.get('pendingTag') ?? '').trim().toLowerCase();
-    const note = String(formData.get('note') ?? '').trim();
-    const breakdownsJson = String(formData.get('breakdowns') ?? '[]');
-
-    // ── Validation ──
-    if (!name) {
-        return { error: 'Transaction name is required.' };
-    }
-    if (!date) {
-        return { error: 'Date is required.' };
-    }
-
-    const amount = Number(amountRaw);
-    if (!amountRaw || Number.isNaN(amount)) {
-        return { error: 'Amount must be a number.' };
-    }
-
-    let tags: string[];
-    try {
-        tags = JSON.parse(tagsJson);
-        if (!Array.isArray(tags)) {
-            throw new Error();
-        }
-    } catch {
-        return { error: 'Invalid tags data.' };
-    }
-
-    // Include any tag the user typed but didn't press Enter for
-    if (pendingTag && !pendingTag.startsWith('__note:') && !tags.includes(pendingTag)) {
-        tags.push(pendingTag);
-    }
-
-    let breakdowns: BreakdownInput[];
-    try {
-        breakdowns = JSON.parse(breakdownsJson);
-        if (!Array.isArray(breakdowns)) {
-            throw new Error();
-        }
-    } catch {
-        return { error: 'Invalid breakdowns data.' };
-    }
-
-    // Validate each breakdown
-    for (const breakdown of breakdowns) {
-        if (!['from', 'to'].includes(breakdown.type)) {
-            return { error: 'Invalid breakdown type.' };
-        }
-        if (!Number.isFinite(breakdown.amount) || breakdown.amount <= 0) {
-            return { error: 'Breakdown amounts must be positive.' };
-        }
-        if (!Number.isInteger(breakdown.accountId)) {
-            return { error: 'Invalid account in breakdown.' };
-        }
-    }
-
-    // Verify all accounts belong to this user
-    if (breakdowns.length > 0) {
-        const accountIds = [...new Set(breakdowns.map((b) => b.accountId))];
-        const ownedAccounts = await sql`
-            SELECT transaction_account_id
-            FROM transaction_account
-            WHERE user_id = ${userId}
-              AND transaction_account_id = ANY(${accountIds}::int[])
-        `;
-        if (ownedAccounts.length !== accountIds.length) {
-            return { error: 'One or more accounts do not belong to you.' };
-        }
-    }
-
-    // Compute net_amount from breakdowns
-    let totalEarned = 0;
-    let totalSpent = 0;
-    for (const breakdown of breakdowns) {
-        if (breakdown.type === 'to') {
-            totalEarned += breakdown.amount;
-        }
-        else {
-            totalSpent += breakdown.amount;
-        }
-    }
-    const netAmount = totalEarned - totalSpent;
-
-    try {
-        // Use a SQL transaction for atomicity
-        // eslint-disable-next-line @typescript-eslint/no-explicit-any
-        await sql.begin(async (tx: any) => {
-            // 1. Insert transaction
-            const [txRow] = await tx`
-                INSERT INTO transaction (transaction_name, amount, net_amount, date)
-                VALUES (${name}, ${amount}, ${netAmount}, ${date})
-                RETURNING transaction_id
-            `;
-            const transactionId: number = txRow.transaction_id;
-
-            // 2. Insert breakdowns & update account balances
-            for (const breakdown of breakdowns) {
-                const spent = breakdown.type === 'from' ? breakdown.amount : 0;
-                const earned = breakdown.type === 'to' ? breakdown.amount : 0;
-
-                await tx`
-                    INSERT INTO transaction_breakdown
-                        (transaction_id, transaction_account_id, spent_amount, earned_amount)
-                    VALUES (${transactionId}, ${breakdown.accountId}, ${spent}, ${earned})
-                `;
-
-                // Update balance: earned increases, spent decreases
-                await tx`
-                    UPDATE transaction_account
-                    SET balance = balance + ${earned} - ${spent}
-                    WHERE transaction_account_id = ${breakdown.accountId}
-                `;
-            }
-
-            // 3. Handle tags
-            for (const tagName of tags) {
-                const trimmed = tagName.trim().toLowerCase();
-                if (!trimmed || trimmed.startsWith('__note:')) {
-                    continue;
-                }
-
-                // Find or insert tag
-                const existing = await tx`
-                    SELECT tag_id FROM tag WHERE LOWER(tag_name) = ${trimmed}
-                `;
-                let tagId: number;
-                if (existing.length > 0) {
-                    tagId = existing[0].tag_id;
-                } else {
-                    const [inserted] = await tx`
-                        INSERT INTO tag (tag_name) VALUES (${trimmed})
-                        RETURNING tag_id
-                    `;
-                    tagId = inserted.tag_id;
-                }
-
-                // Check if assignment already exists
-                const existingAssignment = await tx`
-                    SELECT tag_assigned_to_transaction_id
-                    FROM tag_assigned_to_transaction
-                    WHERE transaction_id = ${transactionId} AND tag_id = ${tagId}
-                `;
-                if (existingAssignment.length === 0) {
-                    await tx`
-                        INSERT INTO tag_assigned_to_transaction (transaction_id, tag_id)
-                        VALUES (${transactionId}, ${tagId})
-                    `;
-                }
-            }
-
-            // 4. Handle note as a special __note: tag
-            if (note) {
-                const noteTagName = `__note:${note}`;
-
-                const existingNote = await tx`
-                    SELECT tag_id FROM tag WHERE tag_name = ${noteTagName}
-                `;
-                let noteTagId: number;
-                if (existingNote.length > 0) {
-                    noteTagId = existingNote[0].tag_id;
-                } else {
-                    const [inserted] = await tx`
-                        INSERT INTO tag (tag_name) VALUES (${noteTagName})
-                        RETURNING tag_id
-                    `;
-                    noteTagId = inserted.tag_id;
-                }
-
-                await tx`
-                    INSERT INTO tag_assigned_to_transaction (transaction_id, tag_id)
-                    VALUES (${transactionId}, ${noteTagId})
-                `;
-            }
-        });
-    } catch (e) {
-        console.error('addTransaction error:', e);
-        return { error: 'Failed to create transaction.' };
-    }
-
-    revalidatePath('/dashboard');
-    revalidatePath('/history');
-    revalidatePath('/add');
-    return { success: `Transaction "${name}" created.` };
-}
Index: p/(app)/add/add-account-form.tsx
===================================================================
--- app/(app)/add/add-account-form.tsx	(revision 5ee2d79a544d8f29386e3bbc597e35433bb9b89a)
+++ 	(revision )
@@ -1,95 +1,0 @@
-'use client';
-
-import { useActionState, useEffect, useRef } from 'react';
-import { addTransactionAccount, type ActionState } from './actions';
-import { poppins } from '@/app/ui/fonts';
-import {
-    CurrencyDollarIcon,
-    ExclamationCircleIcon,
-    CheckCircleIcon,
-} from '@heroicons/react/24/outline';
-import { Button } from '@/app/ui/button';
-
-export default function AddAccountForm() {
-    const [state, formAction, isPending] = useActionState<ActionState, FormData>(
-        addTransactionAccount,
-        undefined,
-    );
-    const formRef = useRef<HTMLFormElement>(null);
-
-    useEffect(() => {
-        if (state?.success) {
-            formRef.current?.reset();
-        }
-    }, [state]);
-
-    return (
-        <form ref={formRef} action={formAction} className="space-y-4">
-            {/* Account Name */}
-            <div>
-                <label className="block text-xs text-white/50 mb-1.5 pl-1">
-                    Account Name
-                </label>
-                <input
-                    name="name"
-                    type="text"
-                    required
-                    placeholder="e.g. Cash, Bank Card…"
-                    className={`${poppins.className}
-                        w-full h-12 rounded-xl
-                        bg-white/10 border border-white/15
-                        px-4 text-white text-sm
-                        placeholder:text-white/40
-                        focus:outline-none focus:ring-2 focus:ring-blue-500/50
-                    `}
-                />
-            </div>
-
-            {/* Initial Balance */}
-            <div>
-                <label className="block text-xs text-white/50 mb-1.5 pl-1">
-                    Initial Balance (optional)
-                </label>
-                <div className="relative">
-                    <input
-                        name="balance"
-                        type="number"
-                        step="any"
-                        defaultValue="0"
-                        placeholder="0"
-                        className={`${poppins.className}
-                            w-full h-12 rounded-xl
-                            bg-white/10 border border-white/15
-                            pl-10 pr-4 text-white text-sm
-                            placeholder:text-white/40
-                            focus:outline-none focus:ring-2 focus:ring-blue-500/50
-                        `}
-                    />
-                    <CurrencyDollarIcon className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-white/40" />
-                </div>
-            </div>
-
-            {/* Feedback */}
-            {state?.error && (
-                <div className="flex items-center gap-2 text-red-400 text-sm">
-                    <ExclamationCircleIcon className="h-4 w-4 shrink-0" />
-                    {state.error}
-                </div>
-            )}
-            {state?.success && (
-                <div className="flex items-center gap-2 text-green-400 text-sm">
-                    <CheckCircleIcon className="h-4 w-4 shrink-0" />
-                    {state.success}
-                </div>
-            )}
-
-            <Button
-                type="submit"
-                aria-disabled={isPending}
-                className="w-full justify-center h-12 rounded-xl text-sm font-semibold"
-            >
-                {isPending ? 'Creating…' : 'Create Account'}
-            </Button>
-        </form>
-    );
-}
Index: p/(app)/add/add-page-client.tsx
===================================================================
--- app/(app)/add/add-page-client.tsx	(revision 5ee2d79a544d8f29386e3bbc597e35433bb9b89a)
+++ 	(revision )
@@ -1,54 +1,0 @@
-'use client';
-
-import { useState } from 'react';
-import FormSelector, { type FormType } from './form-selector';
-import AddAccountForm from './add-account-form';
-import AddTagForm from './add-tag-form';
-import AddTransactionForm from './add-transaction-form';
-import { poppins } from '@/app/ui/fonts';
-
-type AccountOption = { transaction_account_id: number; account_name: string | null };
-type TagOption = { tag_id: number; tag_name: string };
-
-export default function AddPageClient({
-    accounts,
-    allTags,
-}: {
-    accounts: AccountOption[];
-    allTags: TagOption[];
-}) {
-    const [formType, setFormType] = useState<FormType>('transaction');
-
-    const titles: Record<FormType, string> = {
-        transaction: 'Transaction',
-        account: 'Account',
-        tag: 'Tag',
-    };
-
-    return (
-        <div className="w-full overflow-hidden px-6 pt-10 pb-10">
-            <h1
-                className={`${poppins.className}
-                    text-[32px] leading-tight tracking-tight
-                    font-semibold text-center text-white mb-6
-                `}
-            >
-                Add
-            </h1>
-
-            {/* Selector */}
-            <div className="mb-6">
-                <FormSelector value={formType} onChange={setFormType} />
-            </div>
-
-            {/* Form card */}
-            <div className="rounded-2xl bg-white/5 border border-white/10 p-5">
-                {formType === 'transaction' && (
-                    <AddTransactionForm accounts={accounts} allTags={allTags} />
-                )}
-                {formType === 'account' && <AddAccountForm />}
-                {formType === 'tag' && <AddTagForm />}
-            </div>
-        </div>
-    );
-}
Index: p/(app)/add/add-tag-form.tsx
===================================================================
--- app/(app)/add/add-tag-form.tsx	(revision 5ee2d79a544d8f29386e3bbc597e35433bb9b89a)
+++ 	(revision )
@@ -1,77 +1,0 @@
-'use client';
-
-import { useActionState, useEffect, useRef } from 'react';
-import { addTag, type ActionState } from './actions';
-import { poppins } from '@/app/ui/fonts';
-import {
-    TagIcon,
-    ExclamationCircleIcon,
-    CheckCircleIcon,
-} from '@heroicons/react/24/outline';
-import { Button } from '@/app/ui/button';
-
-export default function AddTagForm() {
-    const [state, formAction, isPending] = useActionState<ActionState, FormData>(
-        addTag,
-        undefined,
-    );
-    const formRef = useRef<HTMLFormElement>(null);
-
-    useEffect(() => {
-        if (state?.success) {
-            formRef.current?.reset();
-        }
-    }, [state]);
-
-    return (
-        <form ref={formRef} action={formAction} className="space-y-4">
-            {/* Tag Name */}
-            <div>
-                <label className="block text-xs text-white/50 mb-1.5 pl-1">
-                    Tag Name
-                </label>
-                <div className="relative">
-                    <input
-                        name="name"
-                        type="text"
-                        required
-                        placeholder="e.g. food, rent, salary…"
-                        className={`${poppins.className}
-                            w-full h-12 rounded-xl
-                            bg-white/10 border border-white/15
-                            pl-10 pr-4 text-white text-sm
-                            placeholder:text-white/40
-                            focus:outline-none focus:ring-2 focus:ring-blue-500/50
-                        `}
-                    />
-                    <TagIcon className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-white/40" />
-                </div>
-                <p className="text-xs text-white/30 mt-1.5 pl-1">
-                    Tags are shared across all users. Will be lowercased.
-                </p>
-            </div>
-
-            {/* Feedback */}
-            {state?.error && (
-                <div className="flex items-center gap-2 text-red-400 text-sm">
-                    <ExclamationCircleIcon className="h-4 w-4 shrink-0" />
-                    {state.error}
-                </div>
-            )}
-            {state?.success && (
-                <div className="flex items-center gap-2 text-green-400 text-sm">
-                    <CheckCircleIcon className="h-4 w-4 shrink-0" />
-                    {state.success}
-                </div>
-            )}
-
-            <Button
-                type="submit"
-                aria-disabled={isPending}
-                className="w-full justify-center h-12 rounded-xl text-sm font-semibold"
-            >
-                {isPending ? 'Creating…' : 'Create Tag'}
-            </Button>
-        </form>
-    );
-}
Index: p/(app)/add/add-transaction-form.tsx
===================================================================
--- app/(app)/add/add-transaction-form.tsx	(revision 5ee2d79a544d8f29386e3bbc597e35433bb9b89a)
+++ 	(revision )
@@ -1,444 +1,0 @@
-'use client';
-
-import { useActionState, useEffect, useRef, useState } from 'react';
-import { addTransaction, type ActionState } from './actions';
-import { poppins } from '@/app/ui/fonts';
-import {
-    CalendarIcon,
-    PlusIcon,
-    XMarkIcon,
-    ExclamationCircleIcon,
-    CheckCircleIcon,
-    ArrowDownTrayIcon,
-    ArrowUpTrayIcon,
-} from '@heroicons/react/24/outline';
-import { Button } from '@/app/ui/button';
-
-type AccountOption = { transaction_account_id: number; account_name: string | null };
-type TagOption = { tag_id: number; tag_name: string };
-
-type Breakdown = {
-    id: number; // client-side key
-    type: 'from' | 'to';
-    accountId: number;
-    amount: string;
-};
-
-let nextId = 1;
-
-export default function AddTransactionForm({
-    accounts,
-    allTags,
-}: {
-    accounts: AccountOption[];
-    allTags: TagOption[];
-}) {
-    const [state, formAction, isPending] = useActionState<ActionState, FormData>(
-        addTransaction,
-        undefined,
-    );
-    const formRef = useRef<HTMLFormElement>(null);
-
-    // ── Breakdowns ──
-    const [breakdowns, setBreakdowns] = useState<Breakdown[]>([]);
-    const [showFieldMenu, setShowFieldMenu] = useState(false);
-    const fieldMenuRef = useRef<HTMLDivElement>(null);
-
-    function addBreakdown(type: 'from' | 'to') {
-        const defaultAccount = accounts[0]?.transaction_account_id ?? 0;
-        setBreakdowns((prev) => [
-            ...prev,
-            { id: nextId++, type, accountId: defaultAccount, amount: '' },
-        ]);
-        setShowFieldMenu(false);
-    }
-
-    function removeBreakdown(id: number) {
-        setBreakdowns((prev) => prev.filter((b) => b.id !== id));
-    }
-
-    function updateBreakdown(id: number, field: Partial<Breakdown>) {
-        setBreakdowns((prev) =>
-            prev.map((b) => (b.id === id ? { ...b, ...field } : b)),
-        );
-    }
-
-    // ── Tags ──
-    const [selectedTags, setSelectedTags] = useState<string[]>([]);
-    const [tagInput, setTagInput] = useState('');
-
-    function toggleTag(name: string) {
-        setSelectedTags((prev) =>
-            prev.includes(name)
-                ? prev.filter((t) => t !== name)
-                : [...prev, name],
-        );
-    }
-
-    function addCustomTag() {
-        const trimmed = tagInput.trim().toLowerCase();
-        if (trimmed && !selectedTags.includes(trimmed)) {
-            setSelectedTags((prev) => [...prev, trimmed]);
-        }
-        setTagInput('');
-    }
-
-    // ── Reset on success ──
-    useEffect(() => {
-        if (state?.success) {
-            formRef.current?.reset();
-            setBreakdowns([]);
-            setSelectedTags([]);
-            setTagInput('');
-        }
-    }, [state]);
-
-    // ── Close field menu on outside click ──
-    useEffect(() => {
-        function handler(e: MouseEvent) {
-            if (
-                fieldMenuRef.current &&
-                !fieldMenuRef.current.contains(e.target as Node)
-            ) {
-                setShowFieldMenu(false);
-            }
-        }
-        document.addEventListener('mousedown', handler);
-        return () => document.removeEventListener('mousedown', handler);
-    }, []);
-
-    // ── Build serialised data for hidden fields ──
-    const breakdownsPayload = breakdowns.map((b) => ({
-        type: b.type,
-        accountId: b.accountId,
-        amount: Number(b.amount) || 0,
-    }));
-
-    const inputClasses = `${poppins.className}
-        w-full min-w-0 max-w-full box-border h-12 rounded-xl
-        bg-white/10 border border-white/15
-        px-4 text-white text-sm
-        placeholder:text-white/40
-        focus:outline-none focus:ring-2 focus:ring-blue-500/50
-    `;
-
-    return (
-        <form ref={formRef} action={formAction} className="space-y-5">
-            {/* Hidden serialised fields */}
-            <input type="hidden" name="tags" value={JSON.stringify(selectedTags)} />
-            <input type="hidden" name="pendingTag" value={tagInput.trim().toLowerCase()} />
-            <input
-                type="hidden"
-                name="breakdowns"
-                value={JSON.stringify(breakdownsPayload)}
-            />
-
-            {/* Transaction Name */}
-            <div>
-                <label className="block text-xs text-white/50 mb-1.5 pl-1">
-                    Name
-                </label>
-                <input
-                    name="name"
-                    type="text"
-                    required
-                    placeholder="e.g. Grocery shopping"
-                    className={inputClasses}
-                />
-            </div>
-
-            {/* Date */}
-            <div>
-                <label className="block text-xs text-white/50 mb-1.5 pl-1">
-                    Date
-                </label>
-                <div className="relative overflow-hidden">
-                    <input
-                        name="date"
-                        type="date"
-                        required
-                        defaultValue={new Date().toISOString().slice(0, 10)}
-                        className={`${inputClasses} pl-10 [color-scheme:dark] [&::-webkit-date-and-time-value]:text-left`}
-                    />
-                    <CalendarIcon className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-white/40" />
-                </div>
-            </div>
-
-            {/* Amount */}
-            <div>
-                <label className="block text-xs text-white/50 mb-1.5 pl-1">
-                    Amount
-                </label>
-                <input
-                    name="amount"
-                    type="number"
-                    step="any"
-                    required
-                    placeholder="0"
-                    className={inputClasses}
-                />
-            </div>
-
-            {/* ── Breakdown fields ── */}
-            {breakdowns.length > 0 && (
-                <div className="space-y-3">
-                    <span className="block text-xs text-white/50 pl-1">
-                        Account Breakdowns
-                    </span>
-                    {breakdowns.map((bd) => (
-                        <div
-                            key={bd.id}
-                            className="
-                                flex items-center gap-2
-                                rounded-xl bg-white/5 border border-white/10
-                                p-3
-                            "
-                        >
-                            {/* Type badge */}
-                            <span
-                                className={`
-                                    shrink-0 flex items-center gap-1
-                                    text-xs font-medium px-2 py-1 rounded-lg
-                                    ${bd.type === 'from'
-                                        ? 'bg-red-500/20 text-red-400'
-                                        : 'bg-green-500/20 text-green-400'
-                                    }
-                                `}
-                            >
-                                {bd.type === 'from' ? (
-                                    <ArrowUpTrayIcon className="h-3.5 w-3.5" />
-                                ) : (
-                                    <ArrowDownTrayIcon className="h-3.5 w-3.5" />
-                                )}
-                                {bd.type === 'from' ? 'From' : 'To'}
-                            </span>
-
-                            {/* Account Select */}
-                            <select
-                                value={bd.accountId}
-                                onChange={(e) =>
-                                    updateBreakdown(bd.id, {
-                                        accountId: Number(e.target.value),
-                                    })
-                                }
-                                className={`${poppins.className}
-                                    flex-1 min-w-0 h-9 rounded-lg
-                                    bg-white/10 border border-white/15
-                                    px-2 text-white text-xs
-                                    focus:outline-none focus:ring-1 focus:ring-blue-500/50
-                                `}
-                            >
-                                {accounts.map((a) => (
-                                    <option
-                                        key={a.transaction_account_id}
-                                        value={a.transaction_account_id}
-                                        className="bg-[#1a1a2e] text-white"
-                                    >
-                                        {a.account_name ?? `Account #${a.transaction_account_id}`}
-                                    </option>
-                                ))}
-                            </select>
-
-                            {/* Amount */}
-                            <input
-                                type="number"
-                                step="any"
-                                placeholder="0"
-                                value={bd.amount}
-                                onChange={(e) =>
-                                    updateBreakdown(bd.id, { amount: e.target.value })
-                                }
-                                className={`${poppins.className}
-                                    w-20 h-9 rounded-lg
-                                    bg-white/10 border border-white/15
-                                    px-2 text-white text-xs text-right
-                                    placeholder:text-white/40
-                                    focus:outline-none focus:ring-1 focus:ring-blue-500/50
-                                `}
-                            />
-
-                            {/* Remove */}
-                            <button
-                                type="button"
-                                onClick={() => removeBreakdown(bd.id)}
-                                className="shrink-0 p-1 rounded-lg hover:bg-white/10 transition-colors"
-                            >
-                                <XMarkIcon className="h-4 w-4 text-white/40" />
-                            </button>
-                        </div>
-                    ))}
-                </div>
-            )}
-
-            {/* Add a Field button */}
-            <div ref={fieldMenuRef} className="relative">
-                <button
-                    type="button"
-                    onClick={() => setShowFieldMenu(!showFieldMenu)}
-                    className="
-                        flex items-center gap-1.5
-                        text-sm text-blue-400 hover:text-blue-300
-                        transition-colors
-                    "
-                >
-                    <PlusIcon className="h-4 w-4" />
-                    Add a field
-                </button>
-
-                {showFieldMenu && (
-                    <div
-                        className="
-                            absolute z-40 mt-1 left-0
-                            w-48 rounded-xl
-                            bg-[#1a1a2e] border border-white/15
-                            shadow-xl overflow-hidden
-                        "
-                    >
-                        <button
-                            type="button"
-                            onClick={() => addBreakdown('from')}
-                            className="
-                                w-full flex items-center gap-2 px-4 py-3
-                                text-sm text-white/80 hover:bg-white/10
-                                transition-colors
-                            "
-                        >
-                            <ArrowUpTrayIcon className="h-4 w-4 text-red-400" />
-                            From Account
-                        </button>
-                        <button
-                            type="button"
-                            onClick={() => addBreakdown('to')}
-                            className="
-                                w-full flex items-center gap-2 px-4 py-3
-                                text-sm text-white/80 hover:bg-white/10
-                                transition-colors
-                            "
-                        >
-                            <ArrowDownTrayIcon className="h-4 w-4 text-green-400" />
-                            To Account
-                        </button>
-                    </div>
-                )}
-            </div>
-
-            {/* ── Tags ── */}
-            <div>
-                <label className="block text-xs text-white/50 mb-1.5 pl-1">
-                    Tags
-                </label>
-
-                {/* Existing tags as toggleable pills */}
-                <div className="flex flex-wrap gap-1.5 mb-2">
-                    {allTags.map((t) => {
-                        const active = selectedTags.includes(t.tag_name);
-                        return (
-                            <button
-                                key={t.tag_id}
-                                type="button"
-                                onClick={() => toggleTag(t.tag_name)}
-                                className={`
-                                    px-2.5 py-1 rounded-full text-xs font-medium
-                                    transition-colors border
-                                    ${active
-                                        ? 'bg-blue-500/30 border-blue-400/50 text-blue-300'
-                                        : 'bg-white/5 border-white/10 text-white/50 hover:bg-white/10'
-                                    }
-                                `}
-                            >
-                                {t.tag_name}
-                            </button>
-                        );
-                    })}
-                </div>
-
-                {/* Custom tag pills (not from DB yet) */}
-                {selectedTags
-                    .filter((s) => !allTags.some((t) => t.tag_name === s))
-                    .map((custom) => (
-                        <span
-                            key={custom}
-                            className="
-                                inline-flex items-center gap-1
-                                mr-1.5 mb-1.5
-                                px-2.5 py-1 rounded-full text-xs font-medium
-                                bg-blue-500/30 border border-blue-400/50 text-blue-300
-                            "
-                        >
-                            {custom}
-                            <button
-                                type="button"
-                                onClick={() => toggleTag(custom)}
-                                className="hover:text-white"
-                            >
-                                <XMarkIcon className="h-3 w-3" />
-                            </button>
-                        </span>
-                    ))}
-
-                {/* Input to add new tag */}
-                <input
-                    type="text"
-                    value={tagInput}
-                    onChange={(e) => setTagInput(e.target.value)}
-                    onKeyDown={(e) => {
-                        if (e.key === 'Enter') {
-                            e.preventDefault();
-                            addCustomTag();
-                        }
-                    }}
-                    placeholder="Type a new tag + Enter"
-                    className={`${poppins.className}
-                        w-full h-10 rounded-xl
-                        bg-white/5 border border-white/10
-                        px-3 text-white text-xs
-                        placeholder:text-white/30
-                        focus:outline-none focus:ring-1 focus:ring-blue-500/50
-                    `}
-                />
-            </div>
-
-            {/* ── Note ── */}
-            <div>
-                <label className="block text-xs text-white/50 mb-1.5 pl-1">
-                    Note (optional)
-                </label>
-                <textarea
-                    name="note"
-                    rows={2}
-                    placeholder="Any extra details…"
-                    className={`${poppins.className}
-                        w-full rounded-xl
-                        bg-white/10 border border-white/15
-                        px-4 py-3 text-white text-sm
-                        placeholder:text-white/40
-                        focus:outline-none focus:ring-2 focus:ring-blue-500/50
-                        resize-none
-                    `}
-                />
-            </div>
-
-            {/* ── Feedback ── */}
-            {state?.error && (
-                <div className="flex items-center gap-2 text-red-400 text-sm">
-                    <ExclamationCircleIcon className="h-4 w-4 shrink-0" />
-                    {state.error}
-                </div>
-            )}
-            {state?.success && (
-                <div className="flex items-center gap-2 text-green-400 text-sm">
-                    <CheckCircleIcon className="h-4 w-4 shrink-0" />
-                    {state.success}
-                </div>
-            )}
-
-            <Button
-                type="submit"
-                aria-disabled={isPending}
-                className="w-full justify-center h-12 rounded-xl text-sm font-semibold"
-            >
-                {isPending ? 'Creating…' : 'Create Transaction'}
-            </Button>
-        </form>
-    );
-}
Index: p/(app)/add/form-selector.tsx
===================================================================
--- app/(app)/add/form-selector.tsx	(revision 5ee2d79a544d8f29386e3bbc597e35433bb9b89a)
+++ 	(revision )
@@ -1,88 +1,0 @@
-'use client';
-
-import { useState, useRef, useEffect } from 'react';
-import { ChevronDownIcon } from '@heroicons/react/24/outline';
-import { poppins } from '@/app/ui/fonts';
-
-const FORM_OPTIONS = [
-    { value: 'transaction', label: 'Transaction' },
-    { value: 'account', label: 'Transaction Account' },
-    { value: 'tag', label: 'Tag' },
-] as const;
-
-export type FormType = (typeof FORM_OPTIONS)[number]['value'];
-
-export default function FormSelector({
-    value,
-    onChange,
-}: {
-    value: FormType;
-    onChange: (v: FormType) => void;
-}) {
-    const [open, setOpen] = useState(false);
-    const ref = useRef<HTMLDivElement>(null);
-
-    useEffect(() => {
-        function handleClick(e: MouseEvent) {
-            if (ref.current && !ref.current.contains(e.target as Node)) {
-                setOpen(false);
-            }
-        }
-        document.addEventListener('mousedown', handleClick);
-        return () => document.removeEventListener('mousedown', handleClick);
-    }, []);
-
-    const currentLabel = FORM_OPTIONS.find((o) => o.value === value)?.label;
-
-    return (
-        <div ref={ref} className="relative w-full">
-            <button
-                type="button"
-                onClick={() => setOpen(!open)}
-                className={`${poppins.className}
-                    w-full flex items-center justify-center
-                    h-12 rounded-xl
-                    bg-white/10 border border-white/15
-                    px-4 text-white text-2xl
-                    transition-colors hover:bg-white/15
-                `}
-            >
-                <span className='pr-2'>{currentLabel}</span>
-                <ChevronDownIcon
-                    className={`h-4 w-4 text-white/60 transition-transform ${open ? 'rotate-180' : ''
-                        }`}
-                />
-            </button>
-
-            {open && (
-                <div
-                    className="
-                        absolute z-50 mt-1 w-full
-                        rounded-xl bg-[#1a1a2e] border border-white/15
-                        shadow-xl overflow-hidden
-                    "
-                >
-                    {FORM_OPTIONS.map((opt) => (
-                        <button
-                            key={opt.value}
-                            type="button"
-                            onClick={() => {
-                                onChange(opt.value);
-                                setOpen(false);
-                            }}
-                            className={`
-                                w-full text-left px-4 py-3 text-sm transition-colors
-                                ${opt.value === value
-                                    ? 'bg-blue-500/20 text-blue-400'
-                                    : 'text-white/80 hover:bg-white/10'
-                                }
-                            `}
-                        >
-                            {opt.label}
-                        </button>
-                    ))}
-                </div>
-            )}
-        </div>
-    );
-}
Index: p/(app)/add/page.tsx
===================================================================
--- app/(app)/add/page.tsx	(revision 5ee2d79a544d8f29386e3bbc597e35433bb9b89a)
+++ 	(revision )
@@ -1,23 +1,0 @@
-import { auth } from '@/auth';
-import { redirect } from 'next/navigation';
-import { getUserTransactionAccounts, getAllTags } from '@/app/lib/queries';
-import AddPageClient from './add-page-client';
-
-export default async function Page() {
-    const session = await auth();
-    if (!session?.user?.id) {
-        redirect('/login?callbackUrl=/add');
-    }
-
-    const userId = Number(session.user.id);
-    if (!Number.isInteger(userId)) {
-        redirect('/login?callbackUrl=/add');
-    }
-
-    const [accounts, allTags] = await Promise.all([
-        getUserTransactionAccounts(userId),
-        getAllTags(),
-    ]);
-
-    return <AddPageClient accounts={accounts} allTags={allTags} />;
-}
Index: p/(app)/analytics/analytics-client.tsx
===================================================================
--- app/(app)/analytics/analytics-client.tsx	(revision 5ee2d79a544d8f29386e3bbc597e35433bb9b89a)
+++ 	(revision )
@@ -1,671 +1,0 @@
-"use client";
-
-import { useEffect, useMemo, useRef, useState } from 'react';
-import { usePathname, useRouter, useSearchParams } from 'next/navigation';
-import { CalendarDaysIcon, CheckIcon, XMarkIcon } from '@heroicons/react/24/outline';
-import { formatMKD } from '@/app/lib/utils';
-import UrlSearchInput from '@/app/ui/url-search-input';
-import AccountFilterIcon from '@/app/ui/account-filter-icon';
-import type { AnalyticsData, AnalyticsTagTotal, AnalyticsTrendPoint } from '@/app/lib/queries';
-
-const COLORS = ['#60a5fa', '#34d399', '#fbbf24', '#f472b6', '#a78bfa', '#fb7185', '#22d3ee', '#f97316'];
-const ANALYTICS_KEYS = ['query', 'accountId', 'period', 'startDate', 'endDate', 'focusTags'] as const;
-const NAVIGATION_START_EVENT = 'fein:navigation-start';
-
-export default function AnalyticsClient({
-    data,
-    userId,
-}: {
-    data: AnalyticsData;
-    userId: number;
-}) {
-    const searchParams = useSearchParams();
-    const pathname = usePathname();
-    const { replace } = useRouter();
-    const [trendZoomOpen, setTrendZoomOpen] = useState(false);
-
-    const query = searchParams.get('query') ?? '';
-    const period = (searchParams.get('period') ?? 'month') as 'month' | 'year' | 'range';
-    const startDate = searchParams.get('startDate') ?? '';
-    const endDate = searchParams.get('endDate') ?? '';
-    const selectedFocusTags = (searchParams.get('focusTags') ?? '').split(',').filter(Boolean);
-
-    const storageKey = `fein:analyticsState:v1:${userId}`;
-
-    function navigateWithIndicator(nextUrl: string) {
-        window.dispatchEvent(new Event(NAVIGATION_START_EVENT));
-        replace(nextUrl, { scroll: false });
-    }
-
-    useEffect(() => {
-        try {
-            const raw = localStorage.getItem(storageKey);
-            if (!raw) {
-                return;
-            }
-
-            const hasActiveParams = ANALYTICS_KEYS.some((key) => searchParams.get(key));
-            if (hasActiveParams) {
-                return;
-            }
-
-            const stored = JSON.parse(raw) as Record<string, string | undefined>;
-            const params = new URLSearchParams();
-            for (const key of ANALYTICS_KEYS) {
-                const value = stored[key];
-                if (value) {
-                    params.set(key, value);
-                }
-            }
-
-            const nextQuery = params.toString();
-            if (nextQuery) {
-                navigateWithIndicator(`${pathname}?${nextQuery}`);
-            }
-        } catch {
-            // ignore storage errors
-        }
-        // eslint-disable-next-line react-hooks/exhaustive-deps
-    }, [storageKey, userId]);
-
-    useEffect(() => {
-        try {
-            const snapshot: Record<string, string | undefined> = {};
-            for (const key of ANALYTICS_KEYS) {
-                const value = searchParams.get(key) ?? undefined;
-                if (value) {
-                    snapshot[key] = value;
-                }
-            }
-
-            localStorage.setItem(storageKey, JSON.stringify(snapshot));
-        } catch {
-            // ignore storage errors
-        }
-    }, [searchParams, storageKey]);
-
-    const updateParams = (mutate: (params: URLSearchParams) => void) => {
-        const params = new URLSearchParams(searchParams);
-        mutate(params);
-        const nextQuery = params.toString();
-        navigateWithIndicator(nextQuery ? `${pathname}?${nextQuery}` : pathname);
-    };
-
-    const setPeriod = (value: 'month' | 'year' | 'range') => {
-        updateParams((params) => {
-            params.set('period', value);
-            if (value !== 'range') {
-                params.delete('startDate');
-                params.delete('endDate');
-            } else if (!params.get('startDate') || !params.get('endDate')) {
-                const now = new Date();
-                const start = new Date(now.getFullYear(), now.getMonth(), 1);
-                const end = new Date(now.getFullYear(), now.getMonth() + 1, 0);
-                params.set('startDate', toDateInputValue(start));
-                params.set('endDate', toDateInputValue(end));
-            }
-        });
-    };
-
-    const setRangeDate = (key: 'startDate' | 'endDate', value: string) => {
-        updateParams((params) => {
-            params.set('period', 'range');
-            if (value) {
-                params.set(key, value);
-            } else {
-                params.delete(key);
-            }
-        });
-    };
-
-    const toggleFocusTag = (tag: string) => {
-        updateParams((params) => {
-            const current = (params.get('focusTags') ?? '').split(',').filter(Boolean);
-            const next = current.includes(tag)
-                ? current.filter((value) => value !== tag)
-                : [...current, tag];
-
-            if (next.length > 0) {
-                params.set('focusTags', next.join(','));
-            } else {
-                params.delete('focusTags');
-            }
-        });
-    };
-
-    const clearAllFilters = () => {
-        navigateWithIndicator(pathname);
-    };
-
-    const mainTagSlices = useMemo(() => toSlices(data.tagTotals), [data.tagTotals]);
-    const focusTagSlices = useMemo(() => toSlices(data.focusTagTotals), [data.focusTagTotals]);
-    const visibleTags = useMemo(() => {
-        if (!query.trim()) return data.tags;
-        const lowered = query.trim().toLowerCase();
-        return data.tags.filter((tag) => tag.toLowerCase().includes(lowered));
-    }, [data.tags, query]);
-
-    return (
-        <div className="mt-8 space-y-6 text-white">
-            <div className="rounded-3xl border border-white/10 bg-black/25 backdrop-blur-md p-4 md:p-5 space-y-4">
-                <div className="flex gap-2 items-center">
-                    <div className="flex-1 min-w-0">
-                        <UrlSearchInput
-                            placeholder="Search tags, accounts, or transactions..."
-                            debounceMs={250}
-                        />
-                    </div>
-                    <AccountFilterIcon
-                        accounts={data.accounts}
-                    />
-                </div>
-
-                <div className="space-y-2">
-                    <div className="inline-flex w-full justify-center gap-2 rounded-2xl border border-white/10 bg-white/5 p-1">
-                        {([['month', 'Monthly'], ['year', 'Yearly'], ['range', 'Range']] as const).map(([value, label]) => (
-                            <button
-                                key={value}
-                                type="button"
-                                onClick={() => setPeriod(value)}
-                                className={`rounded-xl px-4 py-2 text-sm font-medium transition whitespace-nowrap ${period === value
-                                    ? 'bg-sky-500 text-slate-950'
-                                    : 'text-white/65 hover:text-white hover:bg-white/10'
-                                    }`}
-                            >
-                                {label}
-                            </button>
-                        ))}
-                    </div>
-
-                    {period === 'range' && (
-                        <div className="grid grid-cols-2 gap-2">
-                            <DatePickerButton
-                                label="Start Date"
-                                value={startDate}
-                                onChange={(value) => setRangeDate('startDate', value)}
-                            />
-                            <DatePickerButton
-                                label="End Date"
-                                value={endDate}
-                                onChange={(value) => setRangeDate('endDate', value)}
-                            />
-                        </div>
-                    )}
-                </div>
-            </div>
-
-            <RingCard
-                title="Top tags"
-                slices={mainTagSlices}
-                emptyMessage="No tags match the current filters."
-            />
-
-            <TrendCard trend={data.trend} period={period} onZoom={() => setTrendZoomOpen(true)} />
-
-            <div className="rounded-3xl border border-white/10 bg-black/25 backdrop-blur-md p-5 space-y-4">
-                <div className="flex flex-wrap items-center justify-between gap-3">
-                    <div className="text-lg font-semibold text-white">Custom tag mix</div>
-
-                    {selectedFocusTags.length > 0 && (
-                        <button
-                            type="button"
-                            onClick={() => updateParams((params) => params.delete('focusTags'))}
-                            className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/8 px-3 py-1.5 text-xs text-white/70 hover:text-white hover:bg-white/12"
-                        >
-                            <XMarkIcon className="h-4 w-4" />
-                            Clear tag mix
-                        </button>
-                    )}
-                </div>
-
-                <div className="flex flex-wrap gap-2">
-                    {visibleTags.length === 0 ? (
-                        <div className="text-sm text-white/45">No tags available for this account set.</div>
-                    ) : (
-                        visibleTags.map((tag) => {
-                            const selected = selectedFocusTags.includes(tag);
-                            return (
-                                <button
-                                    key={tag}
-                                    type="button"
-                                    onClick={() => toggleFocusTag(tag)}
-                                    className={`inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm transition ${selected
-                                        ? 'border-sky-400/40 bg-sky-500/15 text-white'
-                                        : 'border-white/10 bg-white/5 text-white/65 hover:bg-white/10 hover:text-white'
-                                        }`}
-                                >
-                                    {selected && <CheckIcon className="h-4 w-4" />}
-                                    <span>{tag}</span>
-                                </button>
-                            );
-                        })
-                    )}
-                </div>
-
-                {selectedFocusTags.length > 0 && (
-                    <RingCard
-                        title="Selected tags"
-                        slices={focusTagSlices}
-                        emptyMessage="None of the selected tags produced spending in the current filters."
-                        compact
-                    />
-                )}
-            </div>
-
-            <div className="flex justify-center">
-                <button
-                    type="button"
-                    onClick={clearAllFilters}
-                    className="rounded-full border border-white/10 bg-white/5 px-4 py-2 text-sm text-white/70 hover:text-white hover:bg-white/10"
-                >
-                    Reset analytics filters
-                </button>
-            </div>
-
-            {trendZoomOpen && (
-                <TrendZoomModal trend={data.trend} period={period} onClose={() => setTrendZoomOpen(false)} />
-            )}
-        </div>
-    );
-}
-
-function DatePickerButton({
-    label,
-    value,
-    onChange,
-}: {
-    label: string;
-    value: string;
-    onChange: (value: string) => void;
-}) {
-    const inputRef = useRef<HTMLInputElement>(null);
-    const displayValue = value ? formatDateForButton(value) : 'Pick date';
-    const useDirectTapInput = useMemo(() => {
-        if (typeof navigator === 'undefined' || typeof window === 'undefined') {
-            return false;
-        }
-
-        const ua = navigator.userAgent || '';
-        const isIOS =
-            /iPad|iPhone|iPod/.test(ua)
-            || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
-        const isTouch = window.matchMedia('(pointer: coarse)').matches || navigator.maxTouchPoints > 0;
-
-        // iOS/PWA is more reliable with a direct tap on a native date input.
-        return isIOS && isTouch;
-    }, []);
-
-    function openPicker() {
-        const input = inputRef.current;
-        if (!input) return;
-
-        if (typeof input.showPicker === 'function') {
-            input.showPicker();
-            return;
-        }
-
-        input.focus();
-        input.click();
-    }
-
-    return (
-        <div className="relative">
-            <button
-                type="button"
-                onClick={openPicker}
-                aria-label={label}
-                className="flex w-full items-center justify-center gap-3 rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-sm text-white/80 hover:bg-white/10 transition"
-            >
-                <CalendarDaysIcon className="h-4 w-4 shrink-0 text-white/50" />
-                <span className="truncate text-white/75">{displayValue}</span>
-            </button>
-            <input
-                ref={inputRef}
-                type="date"
-                aria-label={label}
-                className={useDirectTapInput
-                    ? 'absolute inset-0 h-full w-full cursor-pointer opacity-0'
-                    : 'pointer-events-none absolute h-0 w-0 opacity-0'}
-                value={value}
-                onChange={(e) => onChange(e.target.value)}
-            />
-        </div>
-    );
-}
-
-function TrendCard({
-    trend,
-    period,
-    onZoom,
-}: {
-    trend: AnalyticsTrendPoint[];
-    period: 'month' | 'year' | 'range';
-    onZoom: () => void;
-}) {
-    const values = trend.map((point) => Number(point.spent));
-    const max = Math.max(...values, 1);
-    const height = 380;
-    const width = Math.max(1200, trend.length * 82);
-    const padX = 76;
-    const padTop = 36;
-    const padBottom = 80;
-    const innerWidth = width - padX * 2;
-    const innerHeight = height - padTop - padBottom;
-    const step = trend.length > 1 ? innerWidth / (trend.length - 1) : 0;
-
-    const linePoints = trend.map((point, index) => {
-        const x = padX + index * step;
-        const y = padTop + innerHeight - (Number(point.spent) / max) * innerHeight;
-        return { x, y, label: point.label };
-    });
-
-    const linePath = linePoints
-        .map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x} ${point.y}`)
-        .join(' ');
-    const areaPath =
-        linePoints.length > 0
-            ? `${linePath} L ${linePoints[linePoints.length - 1].x} ${height - padBottom} L ${linePoints[0].x} ${height - padBottom} Z`
-            : '';
-
-    const tickCount = period === 'year' ? 6 : 8;
-    const tickStep = Math.max(1, Math.ceil(Math.max(linePoints.length, 1) / tickCount));
-
-    return (
-        <button
-            type="button"
-            onClick={onZoom}
-            className="w-full rounded-3xl border border-white/10 bg-black/25 backdrop-blur-md p-5 overflow-hidden text-left transition hover:bg-black/30"
-        >
-            <div className="flex items-center justify-between gap-4">
-                <div className="text-lg font-semibold text-white">Spending trend</div>
-            </div>
-
-            <div className="mt-4 overflow-hidden rounded-2xl border border-white/10 bg-slate-950/60">
-                <div className="overflow-x-auto no-scrollbar">
-                    <svg viewBox={`0 0 ${width} ${height}`} className="h-[320px] min-w-[1000px] w-full">
-                        <defs>
-                            <linearGradient id="trendFill" x1="0" y1="0" x2="0" y2="1">
-                                <stop offset="0%" stopColor="#38bdf8" stopOpacity="0.35" />
-                                <stop offset="100%" stopColor="#38bdf8" stopOpacity="0.02" />
-                            </linearGradient>
-                        </defs>
-
-                        {[0, 0.25, 0.5, 0.75, 1].map((ratio) => {
-                            const y = padTop + innerHeight - innerHeight * ratio;
-                            return (
-                                <g key={ratio}>
-                                    <line x1={padX} x2={width - padX} y1={y} y2={y} stroke="rgba(255,255,255,0.08)" strokeDasharray="5 6" />
-                                    <text x={16} y={y + 4} fill="rgba(255,255,255,0.35)" fontSize="18">
-                                        {formatAxisValue(max * ratio)}
-                                    </text>
-                                </g>
-                            );
-                        })}
-
-                        {areaPath && <path d={areaPath} fill="url(#trendFill)" />}
-                        {linePath && <path d={linePath} fill="none" stroke="#38bdf8" strokeWidth="4" strokeLinecap="round" strokeLinejoin="round" />}
-
-                        {linePoints.map((point) => (
-                            <circle key={`${point.label}-${point.x}`} cx={point.x} cy={point.y} r="6" fill="#e0f2fe" stroke="#0284c7" strokeWidth="4" />
-                        ))}
-
-                        {linePoints.map((point, index) => {
-                            if (index % tickStep !== 0 && index !== linePoints.length - 1) return null;
-                            return (
-                                <text key={`${point.label}-${index}`} x={point.x} y={height - 28} fill="rgba(255,255,255,0.45)" fontSize="16" textAnchor="middle">
-                                    {point.label}
-                                </text>
-                            );
-                        })}
-                    </svg>
-                </div>
-            </div>
-        </button>
-    );
-}
-
-function RingCard({
-    title,
-    slices,
-    emptyMessage,
-    compact = false,
-}: {
-    title: string;
-    slices: { label: string; value: number }[];
-    emptyMessage: string;
-    compact?: boolean;
-}) {
-    const total = slices.reduce((sum, slice) => sum + slice.value, 0);
-    const radius = compact ? 70 : 82;
-    const strokeWidth = compact ? 18 : 22;
-    const circumference = 2 * Math.PI * radius;
-    const segments = slices.reduce(
-        (acc, slice, index) => {
-            const portion = total > 0 ? slice.value / total : 0;
-            const dash = Math.max(portion * circumference, 0.01);
-            acc.push({
-                label: slice.label,
-                color: COLORS[index % COLORS.length],
-                dash,
-                offset: acc.length === 0 ? 0 : acc[acc.length - 1].offset + acc[acc.length - 1].dash,
-            });
-            return acc;
-        },
-        [] as Array<{ label: string; color: string; dash: number; offset: number }>,
-    );
-
-    return (
-        <div className="rounded-3xl border border-white/10 bg-black/25 backdrop-blur-md p-5 overflow-hidden">
-            <div className="text-lg font-semibold text-white">{title}</div>
-
-            {total > 0 ? (
-                <div className="mt-4 space-y-4">
-                    <div className="flex items-center justify-center">
-                        <svg viewBox="0 0 220 220" className={compact ? 'h-48 w-48' : 'h-56 w-56'}>
-                            <circle cx="110" cy="110" r={radius} fill="none" stroke="rgba(255,255,255,0.08)" strokeWidth={strokeWidth} />
-                            {segments.map((segment) => (
-                                <circle
-                                    key={segment.label}
-                                    cx="110"
-                                    cy="110"
-                                    r={radius}
-                                    fill="none"
-                                    stroke={segment.color}
-                                    strokeWidth={strokeWidth}
-                                    strokeDasharray={`${segment.dash} ${circumference}`}
-                                    strokeDashoffset={-segment.offset}
-                                    transform="rotate(-90 110 110)"
-                                    strokeLinecap="butt"
-                                />
-                            ))}
-                            <circle cx="110" cy="110" r={radius - strokeWidth * 0.55} fill="#050816" opacity="0.96" />
-                            <text x="110" y="106" textAnchor="middle" fill="white" fontSize="18" fontWeight="700">
-                                {formatMKD(total)}
-                            </text>
-                            <text x="110" y="128" textAnchor="middle" fill="rgba(255,255,255,0.45)" fontSize="12">
-                                inclusive spend
-                            </text>
-                        </svg>
-                    </div>
-
-                    <div className="space-y-2">
-                        {slices.map((slice, index) => {
-                            const pct = total > 0 ? Math.round((slice.value / total) * 100) : 0;
-                            return (
-                                <div key={slice.label} className="flex items-center gap-3 rounded-2xl border border-white/8 bg-white/5 px-4 py-3 min-w-0">
-                                    <span className="h-3 w-3 rounded-full shrink-0" style={{ backgroundColor: COLORS[index % COLORS.length] }} />
-                                    <div className="min-w-0 flex-1">
-                                        <div className="truncate text-sm font-medium text-white">{slice.label}</div>
-                                        <div className="text-xs text-white/45">{pct}% of total</div>
-                                    </div>
-                                    <div className="text-sm font-semibold text-white whitespace-nowrap">{formatMKD(slice.value)}</div>
-                                </div>
-                            );
-                        })}
-                    </div>
-                </div>
-            ) : (
-                <div className="mt-4 rounded-2xl border border-dashed border-white/12 bg-white/5 px-4 py-8 text-center text-sm text-white/50">
-                    {emptyMessage}
-                </div>
-            )}
-        </div>
-    );
-}
-
-function TrendZoomModal({
-    trend,
-    period,
-    onClose,
-}: {
-    trend: AnalyticsTrendPoint[];
-    period: 'month' | 'year' | 'range';
-    onClose: () => void;
-}) {
-    return (
-        <div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/70 px-4 py-6 backdrop-blur-sm">
-            <div className="relative w-full max-w-[96vw] rounded-3xl border border-white/10 bg-slate-950/95 shadow-2xl">
-                <div className="flex items-center justify-between gap-4 border-b border-white/10 px-4 py-3">
-                    <div className="text-lg font-semibold text-white">Spending trend</div>
-                    <button
-                        type="button"
-                        onClick={onClose}
-                        className="rounded-full border border-white/10 bg-white/5 p-2 text-white/70 hover:text-white hover:bg-white/10"
-                        aria-label="Close trend chart"
-                    >
-                        <XMarkIcon className="h-5 w-5" />
-                    </button>
-                </div>
-                <div className="max-h-[82vh] overflow-auto p-4">
-                    <div className="min-w-[1400px]">
-                        <TrendGraph trend={trend} period={period} zoomed />
-                    </div>
-                </div>
-            </div>
-        </div>
-    );
-}
-
-function TrendGraph({
-    trend,
-    period,
-    zoomed = false,
-}: {
-    trend: AnalyticsTrendPoint[];
-    period: 'month' | 'year' | 'range';
-    zoomed?: boolean;
-}) {
-    const values = trend.map((point) => Number(point.spent));
-    const max = Math.max(...values, 1);
-    const height = zoomed ? 560 : 380;
-    const width = Math.max(1200, trend.length * 82);
-    const padX = zoomed ? 88 : 76;
-    const padTop = zoomed ? 44 : 36;
-    const padBottom = zoomed ? 96 : 80;
-    const innerWidth = width - padX * 2;
-    const innerHeight = height - padTop - padBottom;
-    const step = trend.length > 1 ? innerWidth / (trend.length - 1) : 0;
-
-    const linePoints = trend.map((point, index) => {
-        const x = padX + index * step;
-        const y = padTop + innerHeight - (Number(point.spent) / max) * innerHeight;
-        return { x, y, label: point.label };
-    });
-
-    const linePath = linePoints
-        .map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x} ${point.y}`)
-        .join(' ');
-    const areaPath =
-        linePoints.length > 0
-            ? `${linePath} L ${linePoints[linePoints.length - 1].x} ${height - padBottom} L ${linePoints[0].x} ${height - padBottom} Z`
-            : '';
-
-    const tickCount = period === 'year' ? 6 : 8;
-    const tickStep = Math.max(1, Math.ceil(Math.max(linePoints.length, 1) / tickCount));
-
-    return (
-        <div className="rounded-3xl border border-white/10 bg-black/25 backdrop-blur-md p-5 overflow-hidden">
-            <div className="mb-4 flex items-center justify-between gap-4">
-                <div className="text-lg font-semibold text-white">Spending trend</div>
-            </div>
-
-            <div className="overflow-x-auto rounded-2xl border border-white/10 bg-slate-950/60">
-                <svg viewBox={`0 0 ${width} ${height}`} className="h-[520px] min-w-[1400px] w-full">
-                    <defs>
-                        <linearGradient id="trendFillZoom" x1="0" y1="0" x2="0" y2="1">
-                            <stop offset="0%" stopColor="#38bdf8" stopOpacity="0.35" />
-                            <stop offset="100%" stopColor="#38bdf8" stopOpacity="0.02" />
-                        </linearGradient>
-                    </defs>
-
-                    {[0, 0.25, 0.5, 0.75, 1].map((ratio) => {
-                        const y = padTop + innerHeight - innerHeight * ratio;
-                        return (
-                            <g key={ratio}>
-                                <line x1={padX} x2={width - padX} y1={y} y2={y} stroke="rgba(255,255,255,0.08)" strokeDasharray="5 6" />
-                                <text x={18} y={y + 5} fill="rgba(255,255,255,0.4)" fontSize="18">
-                                    {formatAxisValue(max * ratio)}
-                                </text>
-                            </g>
-                        );
-                    })}
-
-                    {areaPath && <path d={areaPath} fill="url(#trendFillZoom)" />}
-                    {linePath && <path d={linePath} fill="none" stroke="#38bdf8" strokeWidth="4" strokeLinecap="round" strokeLinejoin="round" />}
-
-                    {linePoints.map((point) => (
-                        <circle key={`${point.label}-${point.x}`} cx={point.x} cy={point.y} r="6" fill="#e0f2fe" stroke="#0284c7" strokeWidth="4" />
-                    ))}
-
-                    {linePoints.map((point, index) => {
-                        if (index % tickStep !== 0 && index !== linePoints.length - 1) return null;
-                        return (
-                            <text key={`${point.label}-${index}`} x={point.x} y={height - 32} fill="rgba(255,255,255,0.45)" fontSize="16" textAnchor="middle">
-                                {point.label}
-                            </text>
-                        );
-                    })}
-                </svg>
-            </div>
-        </div>
-    );
-}
-
-function toSlices(items: AnalyticsTagTotal[]) {
-    return items.map((item) => ({
-        label: item.tag_name,
-        value: Number(item.spent),
-    }));
-}
-
-function formatAxisValue(value: number) {
-    if (value <= 0) {
-        return '0';
-    }
-
-    if (value >= 1000) {
-        return `${Math.round(value / 1000)}k`;
-    }
-
-    return String(Math.round(value));
-}
-
-function toDateInputValue(date: Date) {
-    const year = date.getFullYear();
-    const month = String(date.getMonth() + 1).padStart(2, '0');
-    const day = String(date.getDate()).padStart(2, '0');
-    return `${year}-${month}-${day}`;
-}
-
-function formatDateForButton(value: string) {
-    const date = new Date(`${value}T00:00:00`);
-    if (Number.isNaN(date.getTime())) {
-        return value;
-    }
-
-    return new Intl.DateTimeFormat('en-US', {
-        month: 'short',
-        day: 'numeric',
-        year: 'numeric',
-    }).format(date);
-}
Index: p/(app)/analytics/page.tsx
===================================================================
--- app/(app)/analytics/page.tsx	(revision 5ee2d79a544d8f29386e3bbc597e35433bb9b89a)
+++ 	(revision )
@@ -1,63 +1,0 @@
-import { auth } from '@/auth';
-import { redirect } from 'next/navigation';
-import { poppins } from '@/app/ui/fonts';
-import { getAnalyticsData } from '@/app/lib/queries';
-import AnalyticsClient from './analytics-client';
-
-export default async function Page(props: {
-    searchParams?: Promise<{
-        query?: string;
-        accountId?: string;
-        period?: string;
-        startDate?: string;
-        endDate?: string;
-        focusTags?: string;
-    }>;
-}) {
-    const session = await auth();
-    if (!session?.user?.id) {
-        redirect('/login?callbackUrl=/analytics');
-    }
-
-    const userId = Number(session.user.id);
-    if (!Number.isInteger(userId)) {
-        redirect('/login?callbackUrl=/analytics');
-    }
-
-    const searchParams = await props.searchParams;
-    const query = searchParams?.query || '';
-    const accountId = searchParams?.accountId ? Number(searchParams.accountId) : undefined;
-    const period = searchParams?.period === 'year' || searchParams?.period === 'range' ? searchParams.period : 'month';
-    const startDate = searchParams?.startDate || undefined;
-    const endDate = searchParams?.endDate || undefined;
-    const focusTags = searchParams?.focusTags ? searchParams.focusTags.split(',').filter(Boolean) : [];
-
-    const data = await getAnalyticsData({
-        userId,
-        query,
-        accountId: Number.isInteger(accountId) ? accountId : undefined,
-        period,
-        startDate,
-        endDate,
-        focusTags,
-    });
-
-    return (
-        <div className="w-full px-6 pt-10 pb-10">
-            <h1
-                className={`${poppins.className}
-          text-[40px]
-          leading-tight
-          tracking-tight
-          font-semibold
-          text-center
-          text-white
-        `}
-            >
-                Analytics
-            </h1>
-
-            <AnalyticsClient data={data} userId={userId} />
-        </div>
-    );
-}
Index: p/(app)/dashboard/accounts-section.tsx
===================================================================
--- app/(app)/dashboard/accounts-section.tsx	(revision 5ee2d79a544d8f29386e3bbc597e35433bb9b89a)
+++ 	(revision )
@@ -1,371 +1,0 @@
-'use client';
-
-import { useEffect, useMemo, useState } from 'react';
-import type { DashboardAccount } from '@/app/lib/queries';
-import { formatMKD } from '@/app/lib/utils';
-import { ArrowsUpDownIcon } from '@heroicons/react/24/outline';
-
-type Props = {
-    userId: number;
-    accounts: DashboardAccount[];
-};
-
-function storageKey(userId: number) {
-    return `fein:accountsOrder:v1:${userId}`;
-}
-
-function collapsedKey(userId: number) {
-    return `fein:accountsCollapsed:v1:${userId}`;
-}
-
-export default function AccountsSection({ userId, accounts }: Props) {
-    const [collapsed, setCollapsed] = useState(true);
-    const [editMode, setEditMode] = useState(false);
-
-    // order is stored as list of account ids
-    const [order, setOrder] = useState<number[] | null>(null);
-
-    // load order and collapsed state from localStorage
-    useEffect(() => {
-        try {
-            const raw = localStorage.getItem(storageKey(userId));
-            if (!raw) {
-                setOrder(accounts.map((a) => a.transaction_account_id));
-            } else {
-                const parsed = JSON.parse(raw) as number[];
-                setOrder(parsed);
-            }
-        } catch {
-            setOrder(accounts.map((a) => a.transaction_account_id));
-        }
-
-        try {
-            const savedCollapsed = localStorage.getItem(collapsedKey(userId));
-            if (savedCollapsed !== null) {
-                setCollapsed(savedCollapsed === 'true');
-            }
-            // else stays at the default (true)
-        } catch {
-            // ignore
-        }
-        // eslint-disable-next-line react-hooks/exhaustive-deps
-    }, [userId]);
-
-    // keep order in sync if accounts change (new account added)
-    useEffect(() => {
-        if (!order) {
-            return;
-        }
-        const ids = accounts.map((a) => a.transaction_account_id);
-        const missing = ids.filter((id) => !order.includes(id));
-        if (missing.length === 0) {
-            return;
-        }
-
-        const next = [...order, ...missing];
-        setOrder(next);
-        localStorage.setItem(storageKey(userId), JSON.stringify(next));
-        // eslint-disable-next-line react-hooks/exhaustive-deps
-    }, [accounts, order]);
-
-    const accountsById = useMemo(() => {
-        const m = new Map<number, DashboardAccount>();
-        for (const a of accounts) {
-            m.set(a.transaction_account_id, a);
-        }
-        return m;
-    }, [accounts]);
-
-    const orderedAccounts = useMemo(() => {
-        if (!order) {
-            return accounts;
-        }
-        const list: DashboardAccount[] = [];
-        for (const id of order) {
-            const acc = accountsById.get(id);
-            if (acc) {
-                list.push(acc);
-            }
-        }
-        // in case localStorage has old ids, append any not included
-        const appended = accounts.filter(
-            (a) => !list.some((x) => x.transaction_account_id === a.transaction_account_id),
-        );
-
-        return [...list, ...appended];
-    }, [accounts, accountsById, order]);
-
-    const count = orderedAccounts.length;
-    const hasAccounts = count > 0;
-    const canCollapse = count > 1;
-    const canReorder = count > 1;
-    const moreCount = Math.max(0, count - 1);
-
-    useEffect(() => {
-        // If 0 or 1 accounts, collapse/edit should not be possible
-        if (!canCollapse && collapsed) {
-            setCollapsed(false);
-            localStorage.setItem(collapsedKey(userId), 'false');
-        }
-        if (!canReorder && editMode) {
-            setEditMode(false);
-        }
-    }, [canCollapse, canReorder, collapsed, editMode, userId]);
-
-    function persist(nextOrder: number[]) {
-        setOrder(nextOrder);
-        localStorage.setItem(storageKey(userId), JSON.stringify(nextOrder));
-    }
-
-    // drag & drop
-    const [dragId, setDragId] = useState<number | null>(null);
-
-    function move(from: number, to: number) {
-        if (!order) {
-            return;
-        }
-        if (from === to) {
-            return;
-        }
-        const next = [...order];
-        const [item] = next.splice(from, 1);
-        next.splice(to, 0, item);
-        persist(next);
-    }
-
-    function moveUp(idx: number) {
-        if (!order) {
-            return;
-        }
-        if (idx <= 0) {
-            return;
-        }
-        move(idx, idx - 1);
-    }
-
-    function moveDown(idx: number) {
-        if (!order) {
-            return;
-        }
-        if (idx >= order.length - 1) {
-            return;
-        }
-        move(idx, idx + 1);
-    }
-
-    return (
-        <div className="mt-8">
-            {/* Header row */}
-            <div className="flex items-center justify-between">
-                <div className="text-white/90 text-xl font-semibold">Accounts</div>
-
-                <div className="flex items-center gap-3">
-                    {canCollapse && !collapsed && !editMode && (
-                        <button
-                            type="button"
-                            onClick={() => {
-                                setCollapsed(true);
-                                localStorage.setItem(collapsedKey(userId), 'true');
-                            }}
-                            className="text-white/60 hover:text-white/90 text-sm transition"
-                        >
-                            Collapse
-                        </button>
-                    )}
-
-                    {canReorder && (
-                        <button
-                            type="button"
-                            onClick={() => {
-                                setEditMode((v) => !v);
-                                setCollapsed(false); // editing implies expanded
-                                localStorage.setItem(collapsedKey(userId), 'false');
-                            }}
-                            className="text-white/60 hover:text-white/90 text-sm transition flex items-center"
-                            aria-label={editMode ? 'Done reordering' : 'Reorder accounts'}
-                            title={editMode ? 'Done' : 'Reorder'}
-                        >
-                            {editMode ? 'Done' : <ArrowsUpDownIcon className="h-5 w-5" />}
-                        </button>
-                    )}
-                </div>
-            </div>
-
-            {/* Collapsed stack */}
-            {canCollapse && collapsed ? (
-                <div className="mt-5 relative">
-                    {/* Ghost cards behind (blank, same style) */}
-                    <div
-                        aria-hidden
-                        className="
-                            absolute
-                            left-1/2
-                            top-10
-                            -translate-x-1/2
-                            w-[92%]
-                            h-[112px]
-                            rounded-2xl
-                            bg-blue-600/20
-                            border border-white/10
-                            backdrop-blur-md
-                            shadow-lg
-                        "
-                        style={{
-                            transform: 'translateY(10px) scale(0.96)',
-                            zIndex: 1,
-                            filter: 'saturate(0.95)',
-                        }}
-                    />
-
-                    {moreCount > 1 && (
-                        <div
-                            aria-hidden
-                            className="
-                                absolute
-                                left-1/2
-                                top-10
-                                -translate-x-1/2
-                                w-[92%]
-                                h-[112px]
-                                rounded-2xl
-                                bg-blue-700/20
-                                border border-white/10
-                                backdrop-blur-md
-                                shadow-md
-                            "
-                            style={{
-                                transform: 'translateY(45px) scale(0.82)',
-                                zIndex: 0,
-                                filter: 'saturate(0.95)',
-                            }}
-                        />
-                    )}
-
-                    {/* the actual top card */}
-                    <div className="relative z-10">
-                        <AccountCard acc={orderedAccounts[0]} />
-                    </div>
-
-                    {/* Spacer so section takes height */}
-                    <div style={{ height: 70 }} />
-
-                    <div className="flex justify-center items-center">
-                        {moreCount > 0 && (
-                            <button
-                                type="button"
-                                onClick={() => {
-                                    setCollapsed(false);
-                                    localStorage.setItem(collapsedKey(userId), 'false');
-                                }}
-                                className="text-white/60 hover:text-white/90 text-sm transition"
-                            >
-                                Expand {moreCount} more
-                            </button>
-                        )}
-                    </div>
-                </div>
-            ) : (
-                // Expanded list (reorderable in edit mode)
-                <div className="mt-5">
-                    {hasAccounts ? (
-                        <div className="space-y-5">
-                            {orderedAccounts.map((acc, idx) => (
-                                <div
-                                    key={acc.transaction_account_id}
-                                    draggable={editMode}
-                                    onDragStart={() => {
-                                        setDragId(acc.transaction_account_id);
-                                    }}
-                                    onDragOver={(e) => {
-                                        if (!editMode) {
-                                            return;
-                                        }
-                                        e.preventDefault();
-                                    }}
-                                    onDrop={() => {
-                                        if (!editMode) {
-                                            return;
-                                        }
-                                        if (!dragId || !order) {
-                                            return;
-                                        }
-                                        const from = order.indexOf(dragId);
-                                        const to = order.indexOf(acc.transaction_account_id);
-                                        if (from >= 0 && to >= 0) {
-                                            move(from, to);
-                                        }
-                                        setDragId(null);
-                                    }}
-                                    className={editMode ? 'cursor-grab active:cursor-grabbing' : ''}
-                                >
-                                    <AccountCard acc={acc} />
-
-                                    {editMode && order && (
-                                        <div className="mt-2 flex justify-end gap-2">
-                                            <button
-                                                type="button"
-                                                onClick={() => {
-                                                    moveUp(idx);
-                                                }}
-                                                className="text-xs text-white/60 hover:text-white/90 border border-white/10 rounded-lg px-3 py-1 bg-black/20"
-                                            >
-                                                Up
-                                            </button>
-                                            <button
-                                                type="button"
-                                                onClick={() => {
-                                                    moveDown(idx);
-                                                }}
-                                                className="text-xs text-white/60 hover:text-white/90 border border-white/10 rounded-lg px-3 py-1 bg-black/20"
-                                            >
-                                                Down
-                                            </button>
-                                        </div>
-                                    )}
-                                </div>
-                            ))}
-                        </div>
-                    ) : (
-                        <div className="text-white/60 text-sm">No accounts yet.</div>
-                    )}
-                </div>
-            )}
-        </div>
-    );
-}
-
-function AccountCard({
-    acc,
-    compact = false,
-}: {
-    acc: DashboardAccount;
-    compact?: boolean;
-}) {
-    return (
-        <div
-            className={`
-                relative
-                overflow-hidden
-                rounded-2xl
-                px-6
-                ${compact ? 'py-5' : 'py-6'}
-                bg-blue-600/25
-                border
-                border-white/10
-                backdrop-blur-md
-                shadow-lg
-            `}
-        >
-            <div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-white/10 to-transparent" />
-
-            <div className="relative">
-                <div className="text-white text-2xl font-semibold">
-                    {acc.account_name ?? 'Account'}
-                </div>
-                <div className="mt-3 text-white text-2xl font-semibold">
-                    {formatMKD(acc.balance)}
-                </div>
-            </div>
-        </div>
-    );
-}
Index: p/(app)/dashboard/page.tsx
===================================================================
--- app/(app)/dashboard/page.tsx	(revision 5ee2d79a544d8f29386e3bbc597e35433bb9b89a)
+++ 	(revision )
@@ -1,153 +1,0 @@
-import { auth } from '@/auth';
-import { redirect } from 'next/navigation';
-import { poppins } from '@/app/ui/fonts';
-import { getDashboardData } from '@/app/lib/queries';
-import { formatMKD } from '@/app/lib/utils';
-import AccountsSection from './accounts-section';
-
-
-export default async function DashboardPage() {
-    const session = await auth();
-    if (!session?.user?.id) {
-        redirect('/login?callbackUrl=/dashboard');
-    }
-
-    const userId = Number(session.user.id);
-    if (!Number.isInteger(userId)) {
-        redirect('/login?callbackUrl=/dashboard');
-    }
-
-    const data = await getDashboardData(userId);
-
-    const displayName = session.user.name ?? 'Account name';
-
-    return (
-        <div className="w-full px-6 pt-10 pb-10">
-            {/* Header name */}
-            <h1
-                className={`${poppins.className}
-                    text-[44px]
-                    leading-tight
-                    tracking-tight
-                    font-semibold
-                    text-white
-                `}
-            >
-                {displayName}
-            </h1>
-
-            {/* Total balance */}
-            <div className="mt-10">
-                <div className="text-white/80 text-xl font-semibold">Total Balance</div>
-                <div
-                    className={`${poppins.className}
-                        mt-3
-                        text-[40px]
-                        leading-tight
-                        tracking-tight
-                        font-semibold
-                        text-white
-                    `}
-                >
-                    {formatMKD(data.totalBalance)}
-                </div>
-            </div>
-
-            {/* Accounts (stacked cards like screenshot) */}
-            <AccountsSection userId={userId} accounts={data.accounts} />
-
-            {/* Quick analytics cards */}
-            <div className="mt-8 grid grid-cols-3 gap-3">
-                <div className="rounded-xl bg-purple-700/45 border border-white/10 backdrop-blur-md px-3 py-4">
-                    <div className="text-white text-sm font-semibold leading-tight">
-                        Daily budget
-                        <br />
-                        until end of
-                        <br />
-                        month
-                    </div>
-                    <div className="mt-4 text-white text-lg font-semibold">
-                        {formatMKD(data.dailyBudgetUntilEndOfMonth)}
-                    </div>
-                </div>
-
-                <div className="rounded-xl bg-purple-700/45 border border-white/10 backdrop-blur-md px-3 py-4">
-                    <div className="text-white text-sm font-semibold leading-tight">
-                        Three day
-                        <br />
-                        spending
-                        <br />
-                        average
-                    </div>
-                    <div className="mt-4 text-white text-lg font-semibold">
-                        {formatMKD(data.threeDaySpendingAvg)}
-                    </div>
-                </div>
-
-                <div className="rounded-xl bg-purple-700/45 border border-white/10 backdrop-blur-md px-3 py-4">
-                    <div className="text-white text-sm font-semibold leading-tight">
-                        Daily
-                        <br />
-                        spending
-                        <br />
-                        average
-                    </div>
-                    <div className="mt-4 text-white text-lg font-semibold">
-                        {formatMKD(data.dailySpendingAvgMonthToDate)}
-                    </div>
-                </div>
-            </div>
-
-            {/* Transaction history */}
-            <div className="mt-10">
-                <div className="text-white text-2xl font-semibold">Transaction History</div>
-
-                <div className="mt-4 space-y-4">
-                    {data.recentTransactions.length === 0 ? (
-                        <div className="text-white/60 text-sm">
-                            No transactions yet.
-                        </div>
-                    ) : (
-                        data.recentTransactions.map((tx) => {
-                            const net = Number(tx.net_amount);
-                            const isNegative = net < 0;
-
-                            return (
-                                <div
-                                    key={tx.transaction_id}
-                                    className="
-                                        rounded-2xl
-                                        px-5
-                                        py-4
-                                        bg-black/30
-                                        border border-white/10
-                                        backdrop-blur-md
-                                        flex
-                                        items-center
-                                        justify-between
-                                    "
-                                >
-                                    <div>
-                                        <div className="text-white text-lg font-semibold">
-                                            {tx.transaction_name ?? 'Transaction'}
-                                        </div>
-                                        <div className="text-white/50 text-sm">
-                                            {tx.primary_tag ?? ''}
-                                        </div>
-                                    </div>
-
-                                    <div
-                                        className={`text-xl font-semibold ${isNegative ? 'text-amber-400' : 'text-emerald-300'
-                                            }`}
-                                    >
-                                        {formatMKD(net)}
-                                    </div>
-                                </div>
-                            );
-                        })
-                    )}
-                </div>
-            </div>
-        </div>
-    );
-}
Index: p/(app)/history/account-filter.tsx
===================================================================
--- app/(app)/history/account-filter.tsx	(revision 5ee2d79a544d8f29386e3bbc597e35433bb9b89a)
+++ 	(revision )
@@ -1,10 +1,0 @@
-import AccountFilterIcon from '@/app/ui/account-filter-icon';
-import type { TransactionAccountLite } from '@/app/lib/queries';
-
-export default function AccountFilter({
-    accounts,
-}: {
-    accounts: TransactionAccountLite[];
-}) {
-    return <AccountFilterIcon accounts={accounts} resetPageOnChange />;
-}
Index: p/(app)/history/page.tsx
===================================================================
--- app/(app)/history/page.tsx	(revision 5ee2d79a544d8f29386e3bbc597e35433bb9b89a)
+++ 	(revision )
@@ -1,91 +1,0 @@
-import { auth } from '@/auth';
-import { redirect } from 'next/navigation';
-import { Suspense } from 'react';
-import { poppins } from '@/app/ui/fonts';
-import {
-    getHistoryTransactions,
-    getHistoryTransactionPages,
-    getUserTransactionAccounts,
-    getUserTagsForHistory,
-} from '@/app/lib/queries';
-import Search from './search';
-import AccountFilter from './account-filter';
-import TagFilter from './tag-filter';
-import TransactionList from './transaction-list';
-import Pagination from './pagination';
-
-export default async function HistoryPage(props: {
-    searchParams?: Promise<{
-        query?: string;
-        accountId?: string;
-        tags?: string;
-        page?: string;
-    }>;
-}) {
-    const session = await auth();
-    if (!session?.user?.id) {
-        redirect('/login?callbackUrl=/history');
-    }
-
-    const userId = Number(session.user.id);
-    if (!Number.isInteger(userId)) {
-        redirect('/login?callbackUrl=/history');
-    }
-
-    const searchParams = await props.searchParams;
-    const query = searchParams?.query || '';
-    const accountId = searchParams?.accountId
-        ? Number(searchParams.accountId)
-        : undefined;
-    const selectedTags = searchParams?.tags
-        ? searchParams.tags.split(',').filter(Boolean)
-        : [];
-    const currentPage = Number(searchParams?.page) || 1;
-
-    const [transactions, totalPages, accounts, allTags] = await Promise.all([
-        getHistoryTransactions({ userId, accountId, query, tags: selectedTags, page: currentPage }),
-        getHistoryTransactionPages({ userId, accountId, query, tags: selectedTags }),
-        getUserTransactionAccounts(userId),
-        getUserTagsForHistory(userId),
-    ]);
-
-    return (
-        <div className="w-full px-6 pt-10 pb-10">
-            <h1
-                className={`${poppins.className}
-                    text-[40px]
-                    leading-tight
-                    tracking-tight
-                    font-semibold
-                    text-center
-                    text-white
-                `}
-            >
-                History
-            </h1>
-
-            <div className="mt-8 flex flex-col gap-3">
-                <div className="flex gap-2 items-center">
-                    <div className="flex-1 min-w-0">
-                        <Suspense fallback={null}>
-                            <Search placeholder="Name, tag, or account…" />
-                        </Suspense>
-                    </div>
-                    <Suspense fallback={null}>
-                        <AccountFilter accounts={accounts} />
-                    </Suspense>
-                </div>
-
-                <Suspense fallback={null}>
-                    <TagFilter tags={allTags} />
-                </Suspense>
-            </div>
-
-            <TransactionList transactions={transactions} />
-
-            <Suspense fallback={null}>
-                <Pagination totalPages={totalPages} />
-            </Suspense>
-        </div>
-    );
-}
Index: p/(app)/history/pagination.tsx
===================================================================
--- app/(app)/history/pagination.tsx	(revision 5ee2d79a544d8f29386e3bbc597e35433bb9b89a)
+++ 	(revision )
@@ -1,122 +1,0 @@
-'use client';
-
-import { usePathname, useSearchParams } from 'next/navigation';
-import Link from 'next/link';
-import clsx from 'clsx';
-import { generatePagination } from '@/app/lib/utils';
-import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
-
-export default function Pagination({ totalPages }: { totalPages: number }) {
-    const pathname = usePathname();
-    const searchParams = useSearchParams();
-    const currentPage = Number(searchParams.get('page')) || 1;
-
-    const allPages = generatePagination(currentPage, totalPages);
-
-    function createPageURL(pageNumber: number | string) {
-        const params = new URLSearchParams(searchParams);
-        params.set('page', pageNumber.toString());
-        return `${pathname}?${params.toString()}`;
-    }
-
-    if (totalPages <= 1) return null;
-
-    return (
-        <div className="mt-6 flex items-center justify-center gap-1">
-            <PaginationArrow
-                direction="left"
-                href={createPageURL(currentPage - 1)}
-                isDisabled={currentPage <= 1}
-            />
-
-            {allPages.map((page, index) => {
-                let position: 'first' | 'last' | 'single' | 'middle' | undefined;
-
-                if (index === 0) position = 'first';
-                if (index === allPages.length - 1) position = 'last';
-                if (allPages.length === 1) position = 'single';
-                if (page === '...') position = 'middle';
-
-                return (
-                    <PaginationNumber
-                        key={`${page}-${index}`}
-                        href={createPageURL(page)}
-                        page={page}
-                        position={position}
-                        isActive={currentPage === page}
-                    />
-                );
-            })}
-
-            <PaginationArrow
-                direction="right"
-                href={createPageURL(currentPage + 1)}
-                isDisabled={currentPage >= totalPages}
-            />
-        </div>
-    );
-}
-
-function PaginationNumber({
-    page,
-    href,
-    isActive,
-    position,
-}: {
-    page: number | string;
-    href: string;
-    position?: 'first' | 'last' | 'middle' | 'single';
-    isActive: boolean;
-}) {
-    const className = clsx(
-        'flex h-9 w-9 items-center justify-center text-sm rounded-lg transition',
-        {
-            'rounded-l-xl': position === 'first' || position === 'single',
-            'rounded-r-xl': position === 'last' || position === 'single',
-            'bg-blue-600 text-white': isActive,
-            'text-white/60 hover:text-white hover:bg-white/10': !isActive && position !== 'middle',
-            'text-white/40 pointer-events-none': position === 'middle',
-        },
-    );
-
-    return isActive || position === 'middle' ? (
-        <div className={className}>{page}</div>
-    ) : (
-        <Link href={href} className={className}>
-            {page}
-        </Link>
-    );
-}
-
-function PaginationArrow({
-    href,
-    direction,
-    isDisabled,
-}: {
-    href: string;
-    direction: 'left' | 'right';
-    isDisabled?: boolean;
-}) {
-    const className = clsx(
-        'flex h-9 w-9 items-center justify-center rounded-lg transition',
-        {
-            'pointer-events-none text-white/20': isDisabled,
-            'text-white/60 hover:text-white hover:bg-white/10': !isDisabled,
-        },
-    );
-
-    const icon =
-        direction === 'left' ? (
-            <ChevronLeftIcon className="h-4 w-4" />
-        ) : (
-            <ChevronRightIcon className="h-4 w-4" />
-        );
-
-    return isDisabled ? (
-        <div className={className}>{icon}</div>
-    ) : (
-        <Link href={href} className={className}>
-            {icon}
-        </Link>
-    );
-}
Index: p/(app)/history/search.tsx
===================================================================
--- app/(app)/history/search.tsx	(revision 5ee2d79a544d8f29386e3bbc597e35433bb9b89a)
+++ 	(revision )
@@ -1,5 +1,0 @@
-import UrlSearchInput from '@/app/ui/url-search-input';
-
-export default function Search({ placeholder }: { placeholder: string }) {
-    return <UrlSearchInput placeholder={placeholder} resetPageOnChange />;
-}
Index: p/(app)/history/tag-filter.tsx
===================================================================
--- app/(app)/history/tag-filter.tsx	(revision 5ee2d79a544d8f29386e3bbc597e35433bb9b89a)
+++ 	(revision )
@@ -1,109 +1,0 @@
-'use client';
-
-import { useSearchParams, usePathname, useRouter } from 'next/navigation';
-import { useRef, useCallback } from 'react';
-
-export default function TagFilter({ tags }: { tags: string[] }) {
-    const searchParams = useSearchParams();
-    const pathname = usePathname();
-    const { replace } = useRouter();
-
-    // Read selected tags from URL (?tags=food,transport)
-    const selectedRaw = searchParams.get('tags') ?? '';
-    const selected = selectedRaw ? selectedRaw.split(',').filter(Boolean) : [];
-
-    function toggle(tag: string) {
-        const params = new URLSearchParams(searchParams);
-        params.set('page', '1');
-
-        let next: string[];
-        if (selected.includes(tag)) {
-            next = selected.filter((t) => t !== tag);
-        } else {
-            next = [...selected, tag];
-        }
-
-        if (next.length > 0) {
-            params.set('tags', next.join(','));
-        } else {
-            params.delete('tags');
-        }
-
-        replace(`${pathname}?${params.toString()}`);
-    }
-
-    // ── Drag-to-scroll (mouse + touch) ──
-    const scrollRef = useRef<HTMLDivElement>(null);
-    const isDragging = useRef(false);
-    const startX = useRef(0);
-    const scrollLeft = useRef(0);
-    const dragDistance = useRef(0);
-
-    // 8px threshold: anything less is a tap, more is a drag
-    const DRAG_THRESHOLD = 8;
-
-    const onPointerDown = useCallback((e: React.PointerEvent) => {
-        const el = scrollRef.current;
-        if (!el) return;
-        isDragging.current = true;
-        dragDistance.current = 0;
-        startX.current = e.clientX;
-        scrollLeft.current = el.scrollLeft;
-    }, []);
-
-    const onPointerMove = useCallback((e: React.PointerEvent) => {
-        if (!isDragging.current || !scrollRef.current) return;
-        const dx = e.clientX - startX.current;
-        dragDistance.current = Math.abs(dx);
-        scrollRef.current.scrollLeft = scrollLeft.current - dx;
-    }, []);
-
-    const onPointerUp = useCallback(() => {
-        isDragging.current = false;
-    }, []);
-
-    // Suppress click only when the pointer moved beyond the threshold
-    const onClickCapture = useCallback((e: React.MouseEvent) => {
-        if (dragDistance.current >= DRAG_THRESHOLD) {
-            e.stopPropagation();
-            e.preventDefault();
-        }
-    }, []);
-
-    if (tags.length === 0) return null;
-
-    return (
-        <div
-            ref={scrollRef}
-            className="overflow-x-auto no-scrollbar -mx-6 px-6 cursor-grab active:cursor-grabbing select-none touch-pan-x"
-            onPointerDown={onPointerDown}
-            onPointerMove={onPointerMove}
-            onPointerUp={onPointerUp}
-            onPointerCancel={onPointerUp}
-            onClickCapture={onClickCapture}
-        >
-            <div className="flex gap-2 w-max">
-                {tags.map((tag) => {
-                    const isActive = selected.includes(tag);
-                    return (
-                        <button
-                            key={tag}
-                            type="button"
-                            onClick={() => toggle(tag)}
-                            className={`
-                                whitespace-nowrap rounded-full px-4 py-1.5 text-sm font-medium
-                                border transition-all shrink-0
-                                ${isActive
-                                    ? 'bg-blue-600 border-blue-500 text-white'
-                                    : 'bg-white/10 border-white/10 text-white/60 hover:bg-white/15 hover:text-white/80'
-                                }
-                            `}
-                        >
-                            {tag}
-                        </button>
-                    );
-                })}
-            </div>
-        </div>
-    );
-}
Index: p/(app)/history/transaction-list.tsx
===================================================================
--- app/(app)/history/transaction-list.tsx	(revision 5ee2d79a544d8f29386e3bbc597e35433bb9b89a)
+++ 	(revision )
@@ -1,83 +1,0 @@
-import { formatMKD, formatDateToLocal } from '@/app/lib/utils';
-import type { HistoryTransaction } from '@/app/lib/queries';
-
-export default function TransactionList({
-    transactions,
-}: {
-    transactions: HistoryTransaction[];
-}) {
-    if (transactions.length === 0) {
-        return (
-            <div className="mt-6 text-center text-white/50 text-sm py-10">
-                No transactions found.
-            </div>
-        );
-    }
-
-    return (
-        <div className="mt-4 space-y-3">
-            {transactions.map((tx) => {
-                const net = Number(tx.net_amount);
-                const isNegative = net < 0;
-
-                // postgres.js may return tags as a parsed array or as a PG
-                // array literal string like "{food,transport}". Normalise to
-                // a plain JS string[] so the pills always render.
-                const rawTags: string[] = Array.isArray(tx.tags)
-                    ? tx.tags.filter(Boolean)
-                    : typeof tx.tags === 'string' && (tx.tags as string).length > 2
-                      ? (tx.tags as string)
-                            .replace(/^\{|\}$/g, '')
-                            .split(',')
-                            .map((s) => s.trim())
-                            .filter(Boolean)
-                      : [];
-
-                // Separate note tags (__note:…) from regular tags
-                const tags = rawTags.filter((t) => !t.startsWith('__note:'));
-                const notes = rawTags
-                    .filter((t) => t.startsWith('__note:'))
-                    .map((t) => t.slice('__note:'.length));
-
-                return (
-                    <div
-                        key={tx.transaction_id}
-                        className="rounded-2xl px-5 py-4 bg-black/30 border border-white/10 backdrop-blur-md flex items-center justify-between"
-                    >
-                        <div className="min-w-0 flex-1 mr-3">
-                            <div className="text-white text-lg font-semibold truncate">
-                                {tx.transaction_name ?? 'Transaction'}
-                            </div>
-                            {tags.length > 0 && (
-                                <div className="mt-1.5 flex flex-wrap gap-1.5">
-                                    {tags.map((tag) => (
-                                        <span
-                                            key={tag}
-                                            className="inline-block rounded-full bg-white/15 border border-white/10 px-2.5 py-0.5 text-xs text-white/70"
-                                        >
-                                            {tag}
-                                        </span>
-                                    ))}
-                                </div>
-                            )}
-                            {notes.length > 0 && (
-                                <div className="mt-1 text-xs text-white/40 italic truncate">
-                                    {notes[0]}
-                                </div>
-                            )}
-                            <div className="mt-1 text-xs text-white/40">
-                                {formatDateToLocal(tx.date)}
-                            </div>
-                        </div>
-                        <div
-                            className={`text-xl font-semibold whitespace-nowrap ${isNegative ? 'text-amber-400' : 'text-emerald-300'
-                                }`}
-                        >
-                            {formatMKD(net)}
-                        </div>
-                    </div>
-                );
-            })}
-        </div>
-    );
-}
Index: p/(app)/layout.tsx
===================================================================
--- app/(app)/layout.tsx	(revision 5ee2d79a544d8f29386e3bbc597e35433bb9b89a)
+++ 	(revision )
@@ -1,31 +1,0 @@
-import { auth } from '@/auth';
-import { redirect } from 'next/navigation';
-import BottomNav from '@/app/ui/bottom-nav';
-
-export default async function AppLayout({
-    children,
-}: {
-    children: React.ReactNode;
-}) {
-    const session = await auth();
-
-    if (!session) {
-        redirect('/login?callbackUrl=/dashboard');
-    }
-
-    return (
-        <div className="relative h-full overflow-hidden">
-            {/* Scrollable content inside phone shell */}
-            <main className="h-full overflow-y-auto no-scrollbar pb-[120px]">
-                {children}
-            </main>
-
-            {/* Bottom nav FLOATS INSIDE the phone shell */}
-            <div className="absolute inset-x-0 bottom-0 z-50 flex justify-center pb-4 pointer-events-none">
-                <div className="w-full pointer-events-auto">
-                    <BottomNav />
-                </div>
-            </div>
-        </div>
-    );
-}
Index: p/(app)/profile/actions.ts
===================================================================
--- app/(app)/profile/actions.ts	(revision 5ee2d79a544d8f29386e3bbc597e35433bb9b89a)
+++ 	(revision )
@@ -1,106 +1,0 @@
-'use server';
-
-import { auth } from '@/auth';
-import { redirect } from 'next/navigation';
-import { sql } from '@/app/lib/db';
-import bcrypt from 'bcrypt';
-
-type ActionResult = string | undefined; // string = error message, undefined = success
-
-export async function updateProfile(
-    _prevState: ActionResult,
-    formData: FormData
-): Promise<ActionResult> {
-    const session = await auth();
-    if (!session?.user?.id) {
-        redirect('/login');
-    }
-
-    const userId = Number(session.user.id);
-    if (!Number.isInteger(userId)) {
-        return 'Invalid session. Please log in again.';
-    }
-    const name = String(formData.get('name') ?? '').trim();
-    const email = String(formData.get('email') ?? '').trim().toLowerCase();
-
-    if (!name) {
-        return 'Name is required.';
-    }
-    if (!email || !email.includes('@')) {
-        return 'Please enter a valid email.';
-    }
-
-    try {
-        // eslint-disable-next-line @typescript-eslint/no-explicit-any
-        await sql.begin(async (tx: any) => {
-            // Email already exists check
-            const existing = await tx`
-                SELECT user_id FROM "user"
-                WHERE email = ${email} AND user_id != ${userId}
-            `;
-            if (existing.length > 0) {
-                throw new Error('Email already exists.');
-            }
-
-            await tx`
-                UPDATE "user"
-                SET user_name = ${name},
-                    email = ${email}
-                WHERE user_id = ${userId}
-            `;
-        });
-    } catch (e: any) {
-        if (e instanceof Error && e.message === 'Email already exists.') {
-            return e.message;
-        }
-        throw e;
-    }
-
-    redirect('/profile');
-}
-
-export async function updatePassword(
-    _prevState: ActionResult,
-    formData: FormData
-): Promise<ActionResult> {
-    const session = await auth();
-    if (!session?.user?.id) {
-        redirect('/login');
-    }
-
-    const userId = Number(session.user.id);
-    if (!Number.isInteger(userId)) {
-        return 'Invalid session. Please log in again.';
-    }
-    const currentPassword = String(formData.get('currentPassword') ?? '');
-    const newPassword = String(formData.get('newPassword') ?? '');
-
-    if (newPassword.length < 6) {
-        return 'New password must be at least 6 characters.';
-    }
-
-    const users = await sql`
-        SELECT password
-        FROM "user"
-        WHERE user_id = ${userId}
-    `;
-    const user = users[0];
-    if (!user) {
-        return 'User not found. Please log in again.';
-    }
-
-    const match = await bcrypt.compare(currentPassword, user.password);
-    if (!match) {
-        return 'Current password is incorrect.';
-    }
-
-    const hashed = await bcrypt.hash(newPassword, 10);
-
-    await sql`
-        UPDATE "user"
-        SET password = ${hashed}
-        WHERE user_id = ${userId}
-    `;
-
-    redirect('/profile');
-}
Index: p/(app)/profile/edit/page.tsx
===================================================================
--- app/(app)/profile/edit/page.tsx	(revision 5ee2d79a544d8f29386e3bbc597e35433bb9b89a)
+++ 	(revision )
@@ -1,56 +1,0 @@
-import { auth } from '@/auth';
-import { redirect } from 'next/navigation';
-import { poppins } from '@/app/ui/fonts';
-import { ProfileUpdateForm, PasswordUpdateForm } from '../profile-forms';
-import Link from 'next/link';
-
-
-export default async function EditProfilePage() {
-    const session = await auth();
-    if (!session?.user) {
-        redirect('/login');
-    }
-
-    return (
-        <div className="w-full px-6 pt-10 space-y-10 pb-10">
-
-            {/* Back */}
-            <div className="max-w-md mx-auto">
-                <Link
-                    href="/profile"
-                    className="inline-flex items-center text-white/60 hover:text-white/80 transition"
-                >
-                    <span className={`${poppins.className} text-sm`}>← Back</span>
-                </Link>
-            </div>
-
-            <h1
-                className={`${poppins.className}
-                    text-[40px]
-                    leading-tight
-                    tracking-tight
-                    font-semibold
-                    text-center
-                    text-white
-                `}
-            >
-                Edit profile
-            </h1>
-
-            <div className="max-w-md mx-auto space-y-8 pb-10">
-                {/* Account Info */}
-                <section className="rounded-3xl bg-white/5 backdrop-blur-md border border-white/10 p-6 space-y-5">
-                    <ProfileUpdateForm
-                        defaultName={session.user.name ?? ''}
-                        defaultEmail={session.user.email ?? ''}
-                    />
-                </section>
-
-                {/* Security */}
-                <section className="rounded-3xl bg-white/5 backdrop-blur-md border border-white/10 p-6 space-y-5">
-                    <PasswordUpdateForm />
-                </section>
-            </div>
-        </div>
-    );
-}
Index: p/(app)/profile/page.tsx
===================================================================
--- app/(app)/profile/page.tsx	(revision 5ee2d79a544d8f29386e3bbc597e35433bb9b89a)
+++ 	(revision )
@@ -1,100 +1,0 @@
-import Link from 'next/link';
-import { auth, signOut } from '@/auth';
-import { redirect } from 'next/navigation';
-import { ChevronRightIcon } from '@heroicons/react/24/outline';
-import { poppins } from '@/app/ui/fonts';
-
-export default async function ProfilePage() {
-    const session = await auth();
-    if (!session?.user) redirect('/login');
-
-    const name = session.user.name ?? 'Account name';
-    const email = session.user.email ?? 'account email';
-
-    return (
-        <div className="w-full px-6 pt-6 pb-10">
-            {/* Top title */}
-            <h1
-                className={`${poppins.className}
-                    text-[40px]
-                    font-semibold
-                    text-center
-                    text-white/90
-                    mt-2
-                `}
-            >
-                Profile
-            </h1>
-
-            {/* Center identity */}
-            <div className="mt-24 text-center">
-                <div
-                    className={`${poppins.className}
-                        text-[52px]
-                        leading-[1.05]
-                        font-semibold
-                        text-white
-                        tracking-tight
-                    `}
-                >
-                    {name}
-                </div>
-
-                <div className="mt-6 text-white/45 text-lg">
-                    {email}
-                </div>
-            </div>
-
-            {/* Action list */}
-            <div className="mt-24 space-y-4 max-w-md mx-auto">
-                <Link
-                    href="/profile/edit"
-                    className="
-                        flex items-center justify-between
-                        h-14
-                        rounded-2xl
-                        border border-white/35
-                        bg-white/5
-                        px-5
-                        text-white/55
-                        backdrop-blur-md
-                        transition
-                        hover:bg-white/10
-                    "
-                >
-                    <span className={`${poppins.className} text-base`}>Edit Profile</span>
-                    <ChevronRightIcon className="h-5 w-5 text-white/35" />
-                </Link>
-
-                <form
-                    action={async () => {
-                        'use server';
-                        await signOut({ redirectTo: '/login' });
-                    }}
-                >
-                    <button
-                        type="submit"
-                        className="
-                            w-full
-                            flex items-center justify-between
-                            h-14
-                            rounded-2xl
-                            border border-white/35
-                            bg-white/5
-                            px-5
-                            backdrop-blur-md
-                            transition
-                            hover:bg-white/10
-                        "
-                    >
-                        <span className={`${poppins.className} text-base text-red-300`}>
-                            Sign Out
-                        </span>
-                        {/* no chevron in the screenshot, but if you want it remove the next line */}
-                        <span className="w-5" />
-                    </button>
-                </form>
-            </div>
-        </div>
-    );
-}
Index: p/(app)/profile/profile-forms.tsx
===================================================================
--- app/(app)/profile/profile-forms.tsx	(revision 5ee2d79a544d8f29386e3bbc597e35433bb9b89a)
+++ 	(revision )
@@ -1,98 +1,0 @@
-'use client';
-
-import React from 'react';
-import { updateProfile, updatePassword } from './actions';
-
-function ErrorLine({ error }: { error?: string }) {
-    if (!error) {
-        return null;
-    }
-    return <p className="text-red-400 text-sm">{error}</p>;
-}
-
-export function ProfileUpdateForm({
-    defaultName,
-    defaultEmail,
-}: {
-    defaultName: string;
-    defaultEmail: string;
-}) {
-    const [error, action, pending] = React.useActionState<string | undefined, FormData>(
-        updateProfile,
-        undefined
-    );
-
-    return (
-        <form action={action} className="space-y-4">
-            <div className="space-y-2">
-                <label className="text-white/70 text-sm">Name</label>
-                <input
-                    name="name"
-                    defaultValue={defaultName}
-                    required
-                    className="w-full rounded-xl bg-white/10 border border-white/10 px-4 py-3 text-white focus:ring-2 focus:ring-blue-500 outline-none"
-                />
-            </div>
-
-            <div className="space-y-2">
-                <label className="text-white/70 text-sm">Email</label>
-                <input
-                    name="email"
-                    defaultValue={defaultEmail}
-                    required
-                    className="w-full rounded-xl bg-white/10 border border-white/10 px-4 py-3 text-white focus:ring-2 focus:ring-blue-500 outline-none"
-                />
-            </div>
-
-            <ErrorLine error={error} />
-
-            <button
-                disabled={pending}
-                className="w-full bg-blue-600 hover:bg-blue-500 rounded-xl py-3 text-white font-medium transition disabled:opacity-50 disabled:cursor-not-allowed"
-            >
-                {pending ? 'Saving…' : 'Save Changes'}
-            </button>
-        </form>
-    );
-}
-
-export function PasswordUpdateForm() {
-    const [error, action, pending] = React.useActionState<string | undefined, FormData>(
-        updatePassword,
-        undefined
-    );
-
-    return (
-        <form action={action} className="space-y-4">
-            <div className="space-y-2">
-                <label className="text-white/70 text-sm">Current Password</label>
-                <input
-                    type="password"
-                    name="currentPassword"
-                    required
-                    className="w-full rounded-xl bg-white/10 border border-white/10 px-4 py-3 text-white focus:ring-2 focus:ring-blue-500 outline-none"
-                />
-            </div>
-
-            <div className="space-y-2">
-                <label className="text-white/70 text-sm">New Password</label>
-                <input
-                    type="password"
-                    name="newPassword"
-                    required
-                    minLength={6}
-                    className="w-full rounded-xl bg-white/10 border border-white/10 px-4 py-3 text-white focus:ring-2 focus:ring-blue-500 outline-none"
-                />
-            </div>
-
-            <ErrorLine error={error} />
-
-            <button
-                disabled={pending}
-                className="w-full bg-blue-600 hover:bg-blue-500 rounded-xl py-3 text-white font-medium transition disabled:opacity-50 disabled:cursor-not-allowed"
-            >
-                {pending ? 'Updating…' : 'Update Password'}
-            </button>
-        </form>
-    );
-}
Index: p/(auth)/actions.ts
===================================================================
--- app/(auth)/actions.ts	(revision 5ee2d79a544d8f29386e3bbc597e35433bb9b89a)
+++ 	(revision )
@@ -1,99 +1,0 @@
-'use server'
-
-import { z } from 'zod';
-import { sql } from '@/app/lib/db';
-import { signIn } from '@/auth';
-import bcrypt from "bcryptjs";
-import { AuthError } from 'next-auth';
-
-export async function authenticate(
-    prevState: string | undefined,
-    formData: FormData,
-) {
-    try {
-        const redirectTo =
-            (formData.get('redirectTo') as string)?.startsWith('/')
-                ? (formData.get('redirectTo') as string)
-                : '/dashboard';
-
-        await signIn('credentials', {
-            ...Object.fromEntries(formData),
-            redirectTo,
-        });
-    } catch (error) {
-        if (error instanceof AuthError) {
-            switch (error.type) {
-                case 'CredentialsSignin':
-                    return 'Invalid email or password.';
-                default:
-                    return 'Something went wrong. Please try again.';
-            }
-        }
-        throw error;
-    }
-}
-
-export async function register(
-    prevState: string | undefined,
-    formData: FormData,
-) {
-    const schema = z.object({
-        user_name: z.string().min(1),
-        email: z.string().email(),
-        password: z.string().min(6),
-        redirectTo: z.string().optional(),
-    });
-
-    const parsed = schema.safeParse({
-        user_name: formData.get('user_name'),
-        email: formData.get('email'),
-        password: formData.get('password'),
-        redirectTo: formData.get('redirectTo'),
-    });
-
-    if (!parsed.success) {
-        return 'Invalid form data.';
-    }
-
-    const { user_name, email, password, redirectTo } = parsed.data;
-
-    // sanitize redirect
-    const safeRedirect =
-        redirectTo?.startsWith('/') ? redirectTo : '/dashboard';
-
-    const hashed = await bcrypt.hash(password, 10);
-
-    try {
-        // eslint-disable-next-line @typescript-eslint/no-explicit-any
-        await sql.begin(async (tx: any) => {
-            const existing = await tx`SELECT user_id FROM "user" WHERE email=${email}`;
-
-            if (existing.length > 0) {
-                throw new Error('User already exists.');
-            }
-
-            await tx`
-                INSERT INTO "user" (user_name, email, password)
-                VALUES (${user_name}, ${email}, ${hashed})
-            `;
-        });
-    } catch (e: any) {
-        if (e instanceof Error && e.message === 'User already exists.') {
-            return e.message;
-        }
-        return 'Failed to create user.';
-    }
-
-    try {
-        await signIn('credentials', {
-            email,
-            password,
-            redirectTo: safeRedirect,
-        });
-    } catch (error) {
-        if (error instanceof AuthError) {
-            return 'Account created, but auto-login failed. Please log in.';
-        }
-        throw error;
-    }
-}
Index: p/(auth)/layout.tsx
===================================================================
--- app/(auth)/layout.tsx	(revision 5ee2d79a544d8f29386e3bbc597e35433bb9b89a)
+++ 	(revision )
@@ -1,17 +1,0 @@
-export default function AuthLayout({
-    children,
-}: {
-    children: React.ReactNode;
-}) {
-    return (
-        <div
-            className="
-        flex-1
-        flex
-        flex-col
-      "
-        >
-            {children}
-        </div>
-    );
-}
Index: p/(auth)/login/page.tsx
===================================================================
--- app/(auth)/login/page.tsx	(revision 5ee2d79a544d8f29386e3bbc597e35433bb9b89a)
+++ 	(revision )
@@ -1,46 +1,0 @@
-import LoginForm from '@/app/ui/login-form';
-import { Suspense } from 'react';
-import { Metadata } from 'next';
-import { poppins } from '@/app/ui/fonts';
-
-export const metadata: Metadata = {
-    title: 'Login',
-};
-
-export default function LoginPage() {
-    return (
-        <>
-            <main
-                className="
-                    flex-1
-                    flex
-                    flex-col
-                    items-center
-                    justify-center 
-                    md:justify-center
-                    px-4
-                    mt-[-80]
-                "
-            >
-                <h1
-                    className={`${poppins.className} 
-                        text-[40px] 
-                        leading-tight
-                        tracking-tight
-                        font-semibold 
-                        text-center 
-                        text-white 
-                        antialiased
-                    `}
-                >
-                    Welcome to<br />
-                    FEiN
-                </h1>
-
-                <Suspense>
-                    <LoginForm />
-                </Suspense>
-            </main>
-        </>
-    );
-}
Index: p/(auth)/register/page.tsx
===================================================================
--- app/(auth)/register/page.tsx	(revision 5ee2d79a544d8f29386e3bbc597e35433bb9b89a)
+++ 	(revision )
@@ -1,43 +1,0 @@
-import RegisterForm from '@/app/ui/register-form';
-import { Suspense } from 'react';
-import { Metadata } from 'next';
-import { poppins } from '@/app/ui/fonts';
-
-export const metadata: Metadata = {
-    title: 'Register',
-};
-
-export default function RegisterPage() {
-    return (
-        <main
-            className="
-                flex-1
-                flex
-                flex-col
-                items-center
-                justify-center
-                px-4
-                mt-[-80px]
-            "
-        >
-            <h1
-                className={`${poppins.className}
-                    text-[40px]
-                    leading-tight
-                    tracking-tight
-                    font-semibold
-                    text-center
-                    text-white
-                    antialiased
-                `}
-            >
-                Create an<br />
-                account
-            </h1>
-
-            <Suspense>
-                <RegisterForm />
-            </Suspense>
-        </main>
-    );
-}
Index: p/layout.tsx
===================================================================
--- app/layout.tsx	(revision 5ee2d79a544d8f29386e3bbc597e35433bb9b89a)
+++ 	(revision )
@@ -1,50 +1,0 @@
-import '@/app/ui/global.css';
-import { poppins } from '@/app/ui/fonts';
-import { Metadata } from 'next';
-import GlobalNavigationIndicator from '@/app/ui/global-navigation-indicator';
-
-export const metadata: Metadata = {
-  title: {
-    template: '%s | FEiN',
-    default: 'FEiN',
-  },
-  description: 'Mobile-first web-application for personal finance tracking',
-};
-
-export default function RootLayout({
-  children,
-}: {
-  children: React.ReactNode;
-}) {
-  return (
-    <html lang="en">
-      {/* lock page scroll; the app will scroll inside the phone shell */}
-      <body className={`${poppins.className} antialiased bg-black overflow-hidden`}>
-        {/* Outer background wrapper */}
-        <div className="min-h-screen w-full xs:bg-fein  md:bg-black flex items-center justify-center">
-          {/* Phone shell */}
-          <div
-            className="
-              relative
-              w-full
-              h-screen
-              md:h-[800px]
-              md:max-h-[90vh]
-              md:w-[390px]
-              shadow-xl
-              md:rounded-2xl
-              overflow-hidden
-              flex 
-              flex-col
-              bg-fein
-            "
-          >
-            {children}
-          </div>
-        </div>
-
-        <GlobalNavigationIndicator />
-      </body>
-    </html>
-  );
-}
Index: p/lib/db.ts
===================================================================
--- app/lib/db.ts	(revision 5ee2d79a544d8f29386e3bbc597e35433bb9b89a)
+++ 	(revision )
@@ -1,16 +1,0 @@
-import postgres from 'postgres';
-
-declare global {
-    // eslint-disable-next-line no-var
-    var __fein_sql: ReturnType<typeof postgres> | undefined;
-}
-
-export const sql =
-    global.__fein_sql ??
-    postgres(process.env.POSTGRES_URL!, {
-        ssl: 'require',
-    });
-
-if (process.env.NODE_ENV !== 'production') {
-    global.__fein_sql = sql;
-}
Index: p/lib/definitions.ts
===================================================================
--- app/lib/definitions.ts	(revision 5ee2d79a544d8f29386e3bbc597e35433bb9b89a)
+++ 	(revision )
@@ -1,61 +1,0 @@
-// This file contains type definitions for the data.
-// It describes the shape of the data, and what data type each property should accept.
-
-export type User = {
-  user_id: number;
-  user_name: string;
-  email: string;
-  password: string; // hashed
-};
-
-export type TransactionAccount = {
-  transaction_account_id: number;
-  account_name: string | null;
-  balance: string; // DECIMAL comes back as string with `postgres` driver
-  user_id: number | null;
-};
-
-export type Transaction = {
-  transaction_id: number;
-  transaction_name: string | null;
-  amount: string; // DECIMAL -> string
-  net_amount: string | null; // DECIMAL -> string
-  date: string; // TIMESTAMPTZ -> ISO string
-};
-
-export type Tag = {
-  tag_id: number;
-  tag_name: string;
-};
-
-export type TagAssignedToTransaction = {
-  tag_assigned_to_transaction_id: number;
-  transaction_id: number;
-  tag_id: number;
-};
-
-export type TransactionBreakdown = {
-  transaction_breakdown_id: number;
-  transaction_id: number | null;
-  transaction_account_id: number | null;
-  spent_amount: string | null; // DECIMAL -> string
-  earned_amount: string | null; // DECIMAL -> string
-};
-
-/**
- * Useful “joined/view” shapes (not tables).
- * These make UI and API code nicer.
- */
-
-export type TransactionWithTags = Transaction & {
-  tags: Tag[];
-};
-
-export type TransactionAccountWithBreakdowns = TransactionAccount & {
-  breakdowns: TransactionBreakdown[];
-};
-
-export type TransactionBreakdownResolved = TransactionBreakdown & {
-  transaction?: Transaction;
-  transaction_account?: TransactionAccount;
-};
Index: p/lib/queries.ts
===================================================================
--- app/lib/queries.ts	(revision 5ee2d79a544d8f29386e3bbc597e35433bb9b89a)
+++ 	(revision )
@@ -1,586 +1,0 @@
-import { sql } from './db';
-
-export type DashboardAccount = {
-    transaction_account_id: number;
-    account_name: string | null;
-    balance: string; // DECIMAL comes as string
-};
-
-export type DashboardTransaction = {
-    transaction_id: number;
-    transaction_name: string | null;
-    date: string; // timestamptz as ISO-ish string
-    net_amount: string; // signed: earned - spent
-    primary_tag: string | null;
-};
-
-export type DashboardData = {
-    totalBalance: string; // DECIMAL -> string
-    accounts: DashboardAccount[];
-
-    // Quick analytics cards
-    dailyBudgetUntilEndOfMonth: string; // DECIMAL -> string
-    threeDaySpendingAvg: string; // DECIMAL -> string (spend only, positive)
-    dailySpendingAvgMonthToDate: string; // DECIMAL -> string (spend only, positive)
-
-    recentTransactions: DashboardTransaction[];
-};
-
-export type TransactionAccountLite = {
-    transaction_account_id: number;
-    account_name: string | null;
-};
-
-export type AnalyticsPeriod = 'month' | 'year' | 'range';
-
-export type AnalyticsTagTotal = {
-    tag_name: string;
-    spent: string;
-};
-
-export type AnalyticsTrendPoint = {
-    bucket_key: string;
-    label: string;
-    spent: string;
-};
-
-export type AnalyticsData = {
-    accounts: TransactionAccountLite[];
-    tags: string[];
-    totalSpent: string;
-    tagTotals: AnalyticsTagTotal[];
-    focusTagTotals: AnalyticsTagTotal[];
-    trend: AnalyticsTrendPoint[];
-};
-
-export type HistoryTransaction = {
-    transaction_id: number;
-    transaction_name: string | null;
-    date: string; // ISO from PG
-    net_amount: string; // numeric comes as string via postgres.js
-    tags: string[]; // aggregated
-};
-
-function startOfMonth(d: Date) {
-    return new Date(d.getFullYear(), d.getMonth(), 1);
-}
-function endOfMonth(d: Date) {
-    return new Date(d.getFullYear(), d.getMonth() + 1, 0, 23, 59, 59, 999);
-}
-
-function startOfYear(d: Date) {
-    return new Date(d.getFullYear(), 0, 1);
-}
-
-function endOfYear(d: Date) {
-    return new Date(d.getFullYear(), 11, 31, 23, 59, 59, 999);
-}
-
-function parseDateInput(dateStr: string | undefined, fallback: Date) {
-    if (!dateStr) return fallback;
-    const parsed = new Date(`${dateStr}T00:00:00`);
-    return Number.isNaN(parsed.getTime()) ? fallback : parsed;
-}
-
-function bucketKeyForDate(date: Date, granularity: 'day' | 'month') {
-    const year = date.getFullYear();
-    const month = String(date.getMonth() + 1).padStart(2, '0');
-    if (granularity === 'month') {
-        return `${year}-${month}`;
-    }
-    const day = String(date.getDate()).padStart(2, '0');
-    return `${year}-${month}-${day}`;
-}
-
-function trendLabelForDate(date: Date, granularity: 'day' | 'month') {
-    if (granularity === 'month') {
-        return new Intl.DateTimeFormat('en-US', { month: 'short' }).format(date);
-    }
-
-    return String(date.getDate());
-}
-
-function buildTrendSeries(params: {
-    start: Date;
-    end: Date;
-    granularity: 'day' | 'month';
-    rows: { bucket_key: string; spent: string }[];
-}) {
-    const { start, end, granularity, rows } = params;
-    const lookup = new Map(rows.map((row) => [row.bucket_key, row.spent]));
-    const points: AnalyticsTrendPoint[] = [];
-
-    const cursor = new Date(start);
-    while (cursor <= end) {
-        const key = bucketKeyForDate(cursor, granularity);
-        points.push({
-            bucket_key: key,
-            label: trendLabelForDate(cursor, granularity),
-            spent: lookup.get(key) ?? '0.00',
-        });
-
-        if (granularity === 'month') {
-            cursor.setMonth(cursor.getMonth() + 1, 1);
-        } else {
-            cursor.setDate(cursor.getDate() + 1);
-        }
-    }
-
-    return points;
-}
-
-export async function getAnalyticsData(params: {
-    userId: number;
-    query?: string;
-    accountId?: number;
-    period?: AnalyticsPeriod;
-    startDate?: string;
-    endDate?: string;
-    focusTags?: string[];
-}): Promise<AnalyticsData> {
-    const now = new Date();
-    const period = params.period ?? 'month';
-    const defaultMonthStart = startOfMonth(now);
-    const defaultMonthEnd = endOfMonth(now);
-    const defaultYearStart = startOfYear(now);
-    const defaultYearEnd = endOfYear(now);
-
-    const start =
-        period === 'year'
-            ? defaultYearStart
-            : period === 'range'
-                ? parseDateInput(params.startDate, defaultMonthStart)
-                : defaultMonthStart;
-    const end =
-        period === 'year'
-            ? defaultYearEnd
-            : period === 'range'
-                ? parseDateInput(params.endDate, defaultMonthEnd)
-                : defaultMonthEnd;
-
-    const startBoundary = new Date(start);
-    startBoundary.setHours(0, 0, 0, 0);
-    const endBoundary = new Date(end);
-    endBoundary.setHours(23, 59, 59, 999);
-
-    const granularity: 'day' | 'month' = period === 'year' ? 'month' : 'day';
-    const searchPattern = params.query ? `%${params.query}%` : null;
-    const accountId = params.accountId ?? null;
-    const focusTags = params.focusTags && params.focusTags.length > 0 ? params.focusTags : null;
-    const focusTagCount = focusTags ? focusTags.length : 0;
-    const dateFormat = granularity === 'month' ? 'YYYY-MM' : 'YYYY-MM-DD';
-
-    const [accounts, tags, totalSpentRow, tagTotals, focusTagTotals, trendRows] = await Promise.all([
-        getUserTransactionAccounts(params.userId),
-        getUserTagsForHistory(params.userId),
-        sql<{ spent: string | null }[]>`
-            SELECT COALESCE(SUM(tb.spent_amount), 0)::numeric(12,2) AS spent
-            FROM transaction_breakdown tb
-            JOIN transaction_account ta ON ta.transaction_account_id = tb.transaction_account_id
-            JOIN transaction t ON t.transaction_id = tb.transaction_id
-            WHERE ta.user_id = ${params.userId}
-                AND (${accountId}::int IS NULL OR tb.transaction_account_id = ${accountId})
-                AND t.date >= ${startBoundary.toISOString()}
-                AND t.date <= ${endBoundary.toISOString()}
-                AND (
-                    ${searchPattern}::text IS NULL
-                    OR t.transaction_name ILIKE ${searchPattern}
-                    OR ta.account_name ILIKE ${searchPattern}
-                    OR EXISTS (
-                        SELECT 1
-                        FROM tag_assigned_to_transaction tat2
-                        JOIN tag tg2 ON tg2.tag_id = tat2.tag_id
-                        WHERE tat2.transaction_id = t.transaction_id
-                            AND tg2.tag_name ILIKE ${searchPattern}
-                    )
-                )
-        `,
-        sql<AnalyticsTagTotal[]>`
-            SELECT
-                tg.tag_name,
-                COALESCE(SUM(COALESCE(tb.spent_amount, 0)), 0)::numeric(12,2)::text AS spent
-            FROM transaction t
-            JOIN transaction_breakdown tb ON tb.transaction_id = t.transaction_id
-            JOIN transaction_account ta ON ta.transaction_account_id = tb.transaction_account_id
-            JOIN tag_assigned_to_transaction tat ON tat.transaction_id = t.transaction_id
-            JOIN tag tg ON tg.tag_id = tat.tag_id
-            WHERE ta.user_id = ${params.userId}
-                AND (${accountId}::int IS NULL OR tb.transaction_account_id = ${accountId})
-                AND t.date >= ${startBoundary.toISOString()}
-                AND t.date <= ${endBoundary.toISOString()}
-                AND tg.tag_name NOT LIKE '__note:%'
-                AND (
-                    ${searchPattern}::text IS NULL
-                    OR t.transaction_name ILIKE ${searchPattern}
-                    OR ta.account_name ILIKE ${searchPattern}
-                    OR tg.tag_name ILIKE ${searchPattern}
-                    OR EXISTS (
-                        SELECT 1
-                        FROM tag_assigned_to_transaction tat2
-                        JOIN tag tg2 ON tg2.tag_id = tat2.tag_id
-                        WHERE tat2.transaction_id = t.transaction_id
-                            AND tg2.tag_name ILIKE ${searchPattern}
-                    )
-                )
-            GROUP BY tg.tag_name
-            ORDER BY COALESCE(SUM(COALESCE(tb.spent_amount, 0)), 0) DESC, tg.tag_name ASC
-            LIMIT 12
-        `,
-        focusTagCount > 0
-            ? sql<AnalyticsTagTotal[]>`
-                SELECT
-                    tg.tag_name,
-                    COALESCE(SUM(COALESCE(tb.spent_amount, 0)), 0)::numeric(12,2)::text AS spent
-                FROM transaction t
-                JOIN transaction_breakdown tb ON tb.transaction_id = t.transaction_id
-                JOIN transaction_account ta ON ta.transaction_account_id = tb.transaction_account_id
-                JOIN tag_assigned_to_transaction tat ON tat.transaction_id = t.transaction_id
-                JOIN tag tg ON tg.tag_id = tat.tag_id
-                WHERE ta.user_id = ${params.userId}
-                    AND (${accountId}::int IS NULL OR tb.transaction_account_id = ${accountId})
-                    AND t.date >= ${startBoundary.toISOString()}
-                    AND t.date <= ${endBoundary.toISOString()}
-                    AND tg.tag_name NOT LIKE '__note:%'
-                    AND tg.tag_name = ANY(${focusTags ?? []}::text[])
-                    AND (
-                        ${searchPattern}::text IS NULL
-                        OR t.transaction_name ILIKE ${searchPattern}
-                        OR ta.account_name ILIKE ${searchPattern}
-                        OR tg.tag_name ILIKE ${searchPattern}
-                        OR EXISTS (
-                            SELECT 1
-                            FROM tag_assigned_to_transaction tat2
-                            JOIN tag tg2 ON tg2.tag_id = tat2.tag_id
-                            WHERE tat2.transaction_id = t.transaction_id
-                                AND tg2.tag_name ILIKE ${searchPattern}
-                        )
-                    )
-                GROUP BY tg.tag_name
-                ORDER BY COALESCE(SUM(COALESCE(tb.spent_amount, 0)), 0) DESC, tg.tag_name ASC
-            `
-            : Promise.resolve([] as AnalyticsTagTotal[]),
-        sql<{ bucket_key: string; spent: string }[]>`
-            SELECT
-                TO_CHAR(DATE_TRUNC(${granularity}, t.date), ${dateFormat}) AS bucket_key,
-                COALESCE(SUM(COALESCE(tb.spent_amount, 0)), 0)::numeric(12,2)::text AS spent
-            FROM transaction t
-            JOIN transaction_breakdown tb ON tb.transaction_id = t.transaction_id
-            JOIN transaction_account ta ON ta.transaction_account_id = tb.transaction_account_id
-            WHERE ta.user_id = ${params.userId}
-                AND (${accountId}::int IS NULL OR tb.transaction_account_id = ${accountId})
-                AND t.date >= ${startBoundary.toISOString()}
-                AND t.date <= ${endBoundary.toISOString()}
-                AND (
-                    ${searchPattern}::text IS NULL
-                    OR t.transaction_name ILIKE ${searchPattern}
-                    OR ta.account_name ILIKE ${searchPattern}
-                    OR EXISTS (
-                        SELECT 1
-                        FROM tag_assigned_to_transaction tat2
-                        JOIN tag tg2 ON tg2.tag_id = tat2.tag_id
-                        WHERE tat2.transaction_id = t.transaction_id
-                            AND tg2.tag_name ILIKE ${searchPattern}
-                    )
-                )
-            GROUP BY 1
-            ORDER BY 1 ASC
-        `,
-    ]);
-
-    const trend = buildTrendSeries({
-        start: startBoundary,
-        end: endBoundary,
-        granularity,
-        rows: trendRows,
-    });
-
-    return {
-        accounts,
-        tags,
-        totalSpent: totalSpentRow[0]?.spent ?? '0.00',
-        tagTotals,
-        focusTagTotals,
-        trend,
-    };
-}
-
-export async function getDashboardData(userId: number): Promise<DashboardData> {
-    const now = new Date();
-    const som = startOfMonth(now);
-    const eom = endOfMonth(now);
-
-    // Days remaining including today (budget "until end of month")
-    const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
-    const lastDayStart = new Date(eom.getFullYear(), eom.getMonth(), eom.getDate());
-    const daysLeft =
-        Math.max(
-            1,
-            Math.round((lastDayStart.getTime() - todayStart.getTime()) / (1000 * 60 * 60 * 24)) + 1,
-        );
-
-    // Days passed including today (month-to-date average)
-    const daysPassed = Math.max(1, now.getDate());
-
-    // Accounts + total balance
-    const accounts = await sql<DashboardAccount[]>`
-        SELECT
-        transaction_account_id,
-        account_name,
-        balance
-        FROM transaction_account
-        WHERE user_id = ${userId}
-        ORDER BY transaction_account_id ASC
-    `;
-
-    const totalBalanceRow = await sql<{ total: string | null }[]>`
-        SELECT COALESCE(SUM(balance), 0)::numeric(12,2) AS total
-        FROM transaction_account
-        WHERE user_id = ${userId}
-    `;
-    const totalBalance = totalBalanceRow[0]?.total ?? '0.00';
-
-    // Month spend total (spent_amount only) scoped to user via accounts
-    const monthSpendRow = await sql<{ spent: string | null }[]>`
-        SELECT COALESCE(SUM(tb.spent_amount), 0)::numeric(12,2) AS spent
-        FROM transaction_breakdown tb
-        JOIN transaction_account ta ON ta.transaction_account_id = tb.transaction_account_id
-        JOIN transaction t ON t.transaction_id = tb.transaction_id
-        WHERE ta.user_id = ${userId}
-        AND t.date >= ${som.toISOString()}
-        AND t.date <= ${eom.toISOString()}
-    `;
-    const monthSpent = monthSpendRow[0]?.spent ?? '0.00';
-
-    // Last 3 calendar days spend (including today)
-    const threeDaysAgoStart = new Date(todayStart);
-    threeDaysAgoStart.setDate(threeDaysAgoStart.getDate() - 2); // today + previous 2 days
-    const threeDaySpendRow = await sql<{ spent: string | null }[]>`
-    SELECT COALESCE(SUM(tb.spent_amount), 0)::numeric(12,2) AS spent
-    FROM transaction_breakdown tb
-    JOIN transaction_account ta ON ta.transaction_account_id = tb.transaction_account_id
-    JOIN transaction t ON t.transaction_id = tb.transaction_id
-    WHERE ta.user_id = ${userId}
-      AND t.date >= ${threeDaysAgoStart.toISOString()}
-      AND t.date <= ${now.toISOString()}
-  `;
-    const threeDaySpent = threeDaySpendRow[0]?.spent ?? '0.00';
-
-    // Compute quick cards in JS (still DECIMAL-safe enough for display)
-    const totalBalanceNum = Number(totalBalance);
-    const monthSpentNum = Number(monthSpent);
-    const threeDaySpentNum = Number(threeDaySpent);
-
-    const dailyBudgetUntilEndOfMonth = (totalBalanceNum / daysLeft).toFixed(2);
-    const threeDaySpendingAvg = (threeDaySpentNum / 3).toFixed(2);
-    const dailySpendingAvgMonthToDate = (monthSpentNum / daysPassed).toFixed(2);
-
-    // Recent transactions (scoped via breakdown -> account user)
-    // net_amount = SUM(earned) - SUM(spent) for that user’s breakdown rows
-    // primary_tag = first tag_name (alphabetical) if exists
-    const recentTransactions = await sql<DashboardTransaction[]>`
-        WITH tx AS (
-        SELECT
-            t.transaction_id,
-            t.transaction_name,
-            t.date,
-            (COALESCE(SUM(tb.earned_amount), 0) - COALESCE(SUM(tb.spent_amount), 0))::numeric(12,2) AS net_amount
-        FROM transaction t
-        JOIN transaction_breakdown tb ON tb.transaction_id = t.transaction_id
-        JOIN transaction_account ta ON ta.transaction_account_id = tb.transaction_account_id
-        WHERE ta.user_id = ${userId}
-        GROUP BY t.transaction_id, t.transaction_name, t.date
-        ORDER BY t.date DESC
-        LIMIT 6
-        ),
-        tag_one AS (
-        SELECT
-            tat.transaction_id,
-            MIN(tag.tag_name) AS primary_tag
-        FROM tag_assigned_to_transaction tat
-        JOIN tag ON tag.tag_id = tat.tag_id
-        WHERE tag.tag_name NOT LIKE '__note:%'
-        GROUP BY tat.transaction_id
-        )
-        SELECT
-        tx.transaction_id,
-        tx.transaction_name,
-        tx.date,
-        tx.net_amount::text AS net_amount,
-        tag_one.primary_tag
-        FROM tx
-        LEFT JOIN tag_one ON tag_one.transaction_id = tx.transaction_id
-        ORDER BY tx.date DESC
-    `;
-
-    return {
-        totalBalance,
-        accounts,
-        dailyBudgetUntilEndOfMonth,
-        threeDaySpendingAvg,
-        dailySpendingAvgMonthToDate,
-        recentTransactions,
-    };
-}
-
-export async function getUserTransactionAccounts(userId: number) {
-    const rows = await sql<TransactionAccountLite[]>`
-        SELECT
-            ta.transaction_account_id,
-            ta.account_name
-        FROM transaction_account ta
-        WHERE ta.user_id = ${userId}
-        ORDER BY ta.transaction_account_id ASC
-    `;
-    return rows;
-}
-
-export async function getUserTagsForHistory(userId: number) {
-    // Only tags that appear in user's transactions (through breakdown -> account -> user)
-    const rows = await sql<{ tag_name: string }[]>`
-        SELECT DISTINCT tg.tag_name
-        FROM tag tg
-        JOIN tag_assigned_to_transaction tat ON tat.tag_id = tg.tag_id
-        JOIN transaction t ON t.transaction_id = tat.transaction_id
-        JOIN transaction_breakdown tb ON tb.transaction_id = t.transaction_id
-        JOIN transaction_account ta ON ta.transaction_account_id = tb.transaction_account_id
-        WHERE ta.user_id = ${userId}
-          AND tg.tag_name NOT LIKE '__note:%'
-        ORDER BY tg.tag_name ASC
-    `;
-    return rows.map((r) => r.tag_name);
-}
-
-export async function getAllTags() {
-    const rows = await sql<{ tag_id: number; tag_name: string }[]>`
-        SELECT tag_id, tag_name
-        FROM tag
-        WHERE tag_name NOT LIKE '__note:%'
-        ORDER BY tag_name ASC
-    `;
-    return rows;
-}
-
-export const HISTORY_ITEMS_PER_PAGE = 10;
-
-export async function getHistoryTransactions(params: {
-    userId: number;
-    accountId?: number;
-    query?: string; // searches transaction_name, tag_name, account_name
-    tags?: string[]; // intersection: transaction must have ALL of these tags
-    page?: number;
-}) {
-    const { userId, accountId, query, tags, page = 1 } = params;
-    const offset = (page - 1) * HISTORY_ITEMS_PER_PAGE;
-    const searchPattern = query ? `%${query}%` : null;
-    const tagFilter = tags && tags.length > 0 ? tags : null;
-    const tagCount = tagFilter ? tagFilter.length : 0;
-
-    const rows = await sql<HistoryTransaction[]>`
-        WITH filtered_tx AS (
-            SELECT
-                t.transaction_id,
-                t.transaction_name,
-                t.date,
-                (
-                    COALESCE(SUM(COALESCE(tb.earned_amount, 0)), 0)
-                    -
-                    COALESCE(SUM(COALESCE(tb.spent_amount, 0)), 0)
-                )::text AS net_amount
-            FROM transaction t
-            JOIN transaction_breakdown tb ON tb.transaction_id = t.transaction_id
-            JOIN transaction_account ta ON ta.transaction_account_id = tb.transaction_account_id
-            WHERE ta.user_id = ${userId}
-                AND (${accountId ?? null}::int IS NULL OR tb.transaction_account_id = ${accountId ?? null})
-                AND (
-                    ${searchPattern}::text IS NULL
-                    OR t.transaction_name ILIKE ${searchPattern}
-                    OR ta.account_name ILIKE ${searchPattern}
-                    OR EXISTS (
-                        SELECT 1
-                        FROM tag_assigned_to_transaction tat2
-                        JOIN tag tg2 ON tg2.tag_id = tat2.tag_id
-                        WHERE tat2.transaction_id = t.transaction_id
-                            AND tg2.tag_name ILIKE ${searchPattern}
-                    )
-                )
-                AND (
-                    ${tagCount}::int = 0
-                    OR (
-                        SELECT COUNT(DISTINCT tg_f.tag_name)
-                        FROM tag_assigned_to_transaction tat_f
-                        JOIN tag tg_f ON tg_f.tag_id = tat_f.tag_id
-                        WHERE tat_f.transaction_id = t.transaction_id
-                            AND tg_f.tag_name = ANY(${tagFilter ?? []}::text[])
-                    ) = ${tagCount}
-                )
-            GROUP BY t.transaction_id
-            ORDER BY t.date DESC
-            LIMIT ${HISTORY_ITEMS_PER_PAGE}
-            OFFSET ${offset}
-        )
-        SELECT
-            ft.transaction_id,
-            ft.transaction_name,
-            ft.date::text AS date,
-            ft.net_amount,
-            COALESCE(
-                ARRAY_REMOVE(ARRAY_AGG(DISTINCT tg.tag_name), NULL),
-                ARRAY[]::text[]
-            ) AS tags
-        FROM filtered_tx ft
-        LEFT JOIN tag_assigned_to_transaction tat ON tat.transaction_id = ft.transaction_id
-        LEFT JOIN tag tg ON tg.tag_id = tat.tag_id
-        GROUP BY ft.transaction_id, ft.transaction_name, ft.date, ft.net_amount
-        ORDER BY ft.date DESC
-    `;
-
-    return rows;
-}
-
-export async function getHistoryTransactionPages(params: {
-    userId: number;
-    accountId?: number;
-    query?: string;
-    tags?: string[];
-}) {
-    const { userId, accountId, query, tags } = params;
-    const searchPattern = query ? `%${query}%` : null;
-    const tagFilter = tags && tags.length > 0 ? tags : null;
-    const tagCount = tagFilter ? tagFilter.length : 0;
-
-    const countResult = await sql<{ count: string }[]>`
-        SELECT COUNT(DISTINCT t.transaction_id)::text AS count
-        FROM transaction t
-        JOIN transaction_breakdown tb ON tb.transaction_id = t.transaction_id
-        JOIN transaction_account ta ON ta.transaction_account_id = tb.transaction_account_id
-        LEFT JOIN tag_assigned_to_transaction tat ON tat.transaction_id = t.transaction_id
-        LEFT JOIN tag tg ON tg.tag_id = tat.tag_id
-        WHERE ta.user_id = ${userId}
-            AND (${accountId ?? null}::int IS NULL OR tb.transaction_account_id = ${accountId ?? null})
-            AND (
-                ${searchPattern}::text IS NULL
-                OR t.transaction_name ILIKE ${searchPattern}
-                OR ta.account_name ILIKE ${searchPattern}
-                OR EXISTS (
-                    SELECT 1
-                    FROM tag_assigned_to_transaction tat2
-                    JOIN tag tg2 ON tg2.tag_id = tat2.tag_id
-                    WHERE tat2.transaction_id = t.transaction_id
-                        AND tg2.tag_name ILIKE ${searchPattern}
-                )
-            )
-            AND (
-                ${tagCount}::int = 0
-                OR (
-                    SELECT COUNT(DISTINCT tg_f.tag_name)
-                    FROM tag_assigned_to_transaction tat_f
-                    JOIN tag tg_f ON tg_f.tag_id = tat_f.tag_id
-                    WHERE tat_f.transaction_id = t.transaction_id
-                        AND tg_f.tag_name = ANY(${tagFilter ?? []}::text[])
-                ) = ${tagCount}
-            )
-    `;
-
-    const totalCount = Number(countResult[0]?.count ?? '0');
-    return Math.ceil(totalCount / HISTORY_ITEMS_PER_PAGE);
-}
Index: p/lib/utils.ts
===================================================================
--- app/lib/utils.ts	(revision 5ee2d79a544d8f29386e3bbc597e35433bb9b89a)
+++ 	(revision )
@@ -1,62 +1,0 @@
-export function formatMKD(value: string | number) {
-  let n = typeof value === 'number' ? value : Number(value);
-
-  // Normalize -0 to 0
-  if (Object.is(n, -0) || Math.abs(n) < 0.005) {
-    n = 0;
-  }
-
-  const formatted = new Intl.NumberFormat('en-US', {
-    maximumFractionDigits: 0,
-  }).format(Math.abs(n));
-
-  return n < 0 ? `MKD -${formatted}` : `MKD ${formatted}`;
-}
-
-export const formatDateToLocal = (
-  dateStr: string,
-  locale: string = 'en-US',
-) => {
-  const date = new Date(dateStr);
-  const options: Intl.DateTimeFormatOptions = {
-    day: 'numeric',
-    month: 'short',
-    year: 'numeric',
-  };
-  const formatter = new Intl.DateTimeFormat(locale, options);
-  return formatter.format(date);
-};
-
-
-export const generatePagination = (currentPage: number, totalPages: number) => {
-  // If the total number of pages is 7 or less,
-  // display all pages without any ellipsis.
-  if (totalPages <= 7) {
-    return Array.from({ length: totalPages }, (_, i) => i + 1);
-  }
-
-  // If the current page is among the first 3 pages,
-  // show the first 3, an ellipsis, and the last 2 pages.
-  if (currentPage <= 3) {
-    return [1, 2, 3, '...', totalPages - 1, totalPages];
-  }
-
-  // If the current page is among the last 3 pages,
-  // show the first 2, an ellipsis, and the last 3 pages.
-  if (currentPage >= totalPages - 2) {
-    return [1, 2, '...', totalPages - 2, totalPages - 1, totalPages];
-  }
-
-  // If the current page is somewhere in the middle,
-  // show the first page, an ellipsis, the current page and its neighbors,
-  // another ellipsis, and the last page.
-  return [
-    1,
-    '...',
-    currentPage - 1,
-    currentPage,
-    currentPage + 1,
-    '...',
-    totalPages,
-  ];
-};
Index: p/page.tsx
===================================================================
--- app/page.tsx	(revision 5ee2d79a544d8f29386e3bbc597e35433bb9b89a)
+++ 	(revision )
@@ -1,51 +1,0 @@
-import Link from 'next/link';
-import { poppins } from '@/app/ui/fonts';
-
-export default function Page() {
-  return (
-    <>
-      <main
-        className="
-                    flex-1
-                    flex
-                    flex-col
-                    items-center
-                    justify-center 
-                    md:justify-center
-                    px-4
-                    mt-[-80]
-                "
-      >
-        <h1
-          className={`${poppins.className} 
-                        text-[40px] 
-                        leading-tight
-                        tracking-tight
-                        font-semibold 
-                        text-center
-                        mb-10 
-                        text-white 
-                        antialiased
-                    `}
-        >
-          Welcome to<br />
-          FEiN
-        </h1>
-
-        <Link
-          href="/login"
-          className="flex items-center gap-5 rounded-lg bg-blue-500 px-6 py-3 mb-10 text-sm font-medium text-white transition-colors hover:bg-blue-400 md:text-base"
-        >
-          <span>Log in</span>
-        </Link>
-        <Link
-          href="/register"
-          className="flex items-center gap-5 rounded-lg bg-blue-500 px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-blue-400 md:text-base"
-        >
-          <span>Register</span>
-        </Link>
-
-      </main>
-    </>
-  );
-}
Index: p/ui/account-filter-icon.tsx
===================================================================
--- app/ui/account-filter-icon.tsx	(revision 5ee2d79a544d8f29386e3bbc597e35433bb9b89a)
+++ 	(revision )
@@ -1,158 +1,0 @@
-'use client';
-
-import { useState, useRef, useEffect, useLayoutEffect } from 'react';
-import { createPortal } from 'react-dom';
-import { useSearchParams, usePathname, useRouter } from 'next/navigation';
-import { WalletIcon, CheckIcon } from '@heroicons/react/24/outline';
-import type { TransactionAccountLite } from '@/app/lib/queries';
-
-const NAVIGATION_START_EVENT = 'fein:navigation-start';
-
-export default function AccountFilterIcon({
-    accounts,
-    accountParam = 'accountId',
-    resetPageOnChange = false,
-    onNavigateStart,
-}: {
-    accounts: TransactionAccountLite[];
-    accountParam?: string;
-    resetPageOnChange?: boolean;
-    onNavigateStart?: () => void;
-}) {
-    const searchParams = useSearchParams();
-    const pathname = usePathname();
-    const { replace } = useRouter();
-    const [open, setOpen] = useState(false);
-    const [menuStyle, setMenuStyle] = useState<React.CSSProperties>({});
-    const ref = useRef<HTMLDivElement>(null);
-    const buttonRef = useRef<HTMLButtonElement>(null);
-    const menuRef = useRef<HTMLDivElement>(null);
-
-    const currentId = searchParams.get(accountParam) ?? '';
-    const hasFilter = currentId !== '';
-
-    useEffect(() => {
-        function onClickOutside(e: MouseEvent) {
-            const target = e.target as Node;
-            if (ref.current && !ref.current.contains(target) && !menuRef.current?.contains(target)) {
-                setOpen(false);
-            }
-        }
-        if (open) {
-            document.addEventListener('mousedown', onClickOutside);
-            return () => document.removeEventListener('mousedown', onClickOutside);
-        }
-    }, [open]);
-
-    useLayoutEffect(() => {
-        if (!open || !buttonRef.current) {
-            return;
-        }
-
-        const rect = buttonRef.current.getBoundingClientRect();
-        const dropdownWidth = 224;
-        const gap = 8;
-        const left = Math.max(12, Math.min(rect.right - dropdownWidth, window.innerWidth - dropdownWidth - 12));
-        const top = rect.bottom + gap;
-
-        setMenuStyle({
-            position: 'fixed',
-            left,
-            top,
-            width: dropdownWidth,
-            zIndex: 9999,
-        });
-    }, [open]);
-
-    function select(value: string) {
-        const params = new URLSearchParams(searchParams);
-
-        if (resetPageOnChange) {
-            params.set('page', '1');
-        }
-
-        if (value) {
-            params.set(accountParam, value);
-        } else {
-            params.delete(accountParam);
-        }
-
-        const nextQuery = params.toString();
-        window.dispatchEvent(new Event(NAVIGATION_START_EVENT));
-        onNavigateStart?.();
-        replace(nextQuery ? `${pathname}?${nextQuery}` : pathname, { scroll: false });
-        setOpen(false);
-    }
-
-    return (
-        <div ref={ref} className="relative z-50">
-            <button
-                ref={buttonRef}
-                type="button"
-                onClick={() => setOpen((v) => !v)}
-                className={`
-                    flex items-center justify-center h-12 w-12 rounded-xl border transition
-                    ${hasFilter
-                        ? 'bg-blue-600 border-blue-500 text-white'
-                        : 'bg-white/15 border-white/15 text-white/60 hover:text-white hover:bg-white/20'
-                    }
-                `}
-                title="Filter by account"
-            >
-                <WalletIcon className="h-5 w-5" />
-            </button>
-
-            {open && (
-                createPortal(
-                    <div
-                        ref={menuRef}
-                        style={menuStyle}
-                        className="rounded-xl bg-gray-900/95 border border-white/15 backdrop-blur-lg shadow-2xl py-1 overflow-hidden"
-                    >
-                        <DropdownItem
-                            label="All Accounts"
-                            isSelected={currentId === ''}
-                            onClick={() => select('')}
-                        />
-                        {accounts.map((acc) => {
-                            const id = String(acc.transaction_account_id);
-                            return (
-                                <DropdownItem
-                                    key={id}
-                                    label={acc.account_name ?? `Account #${acc.transaction_account_id}`}
-                                    isSelected={currentId === id}
-                                    onClick={() => select(id)}
-                                />
-                            );
-                        })}
-                    </div>,
-                    document.body,
-                )
-            )}
-        </div>
-    );
-}
-
-function DropdownItem({
-    label,
-    isSelected,
-    onClick,
-}: {
-    label: string;
-    isSelected: boolean;
-    onClick: () => void;
-}) {
-    return (
-        <button
-            type="button"
-            onClick={onClick}
-            className={`
-                w-full flex items-center gap-2 px-4 py-2.5 text-sm text-left transition
-                ${isSelected ? 'text-white bg-white/10' : 'text-white/70 hover:bg-white/5 hover:text-white'}
-            `}
-        >
-            {isSelected && <CheckIcon className="h-4 w-4 shrink-0 text-blue-400" />}
-            <span className={isSelected ? '' : 'pl-6'}>{label}</span>
-        </button>
-    );
-}
Index: p/ui/bottom-nav.tsx
===================================================================
--- app/ui/bottom-nav.tsx	(revision 5ee2d79a544d8f29386e3bbc597e35433bb9b89a)
+++ 	(revision )
@@ -1,109 +1,0 @@
-'use client';
-
-import Link from 'next/link';
-import { usePathname } from 'next/navigation';
-import {
-    HomeIcon,
-    ClockIcon,
-    ChartBarIcon,
-    UserIcon,
-    PlusIcon,
-} from '@heroicons/react/24/outline';
-
-const TABS = [
-    { href: '/dashboard', icon: HomeIcon },
-    { href: '/history', icon: ClockIcon },
-    { href: '/analytics', icon: ChartBarIcon },
-    { href: '/profile', icon: UserIcon },
-];
-
-export default function BottomNav() {
-    const pathname = usePathname();
-
-    const isAddPage = pathname.startsWith('/add');
-
-    return (
-        <div className="relative">
-            {/* Floating bar */}
-            <nav
-                className="
-                    pointer-events-auto
-                    pb-safe
-                    mx-4
-                    mb-0
-                    h-14
-                    rounded-2xl
-                    bg-black/40
-                    backdrop-blur-md
-                    shadow-lg
-                    border
-                    border-white/10
-                    flex
-                    items-center
-                    justify-center
-                    px-6
-                    gap-11
-                "
-            >
-                {/* Left tabs */}
-                <div className="flex gap-11">
-                    {TABS.slice(0, 2).map(({ href, icon: Icon }) => {
-                        const active = pathname.startsWith(href);
-                        return (
-                            <Link key={href} href={href}>
-                                <Icon
-                                    className={`h-6 w-6 ${active ? 'text-blue-500' : 'text-white/60'
-                                        }`}
-                                />
-                            </Link>
-                        );
-                    })}
-                </div>
-
-                {/* Spacer for center button */}
-                <div className="w-12" />
-
-                {/* Right tabs */}
-                <div className="flex gap-11">
-                    {TABS.slice(2).map(({ href, icon: Icon }) => {
-                        const active = pathname.startsWith(href);
-                        return (
-                            <Link key={href} href={href}>
-                                <Icon
-                                    className={`h-6 w-6 ${active ? 'text-blue-500' : 'text-white/60'
-                                        }`}
-                                />
-                            </Link>
-                        );
-                    })}
-                </div>
-            </nav>
-
-            {/* Center plus button */}
-            <Link
-                href="/add"
-                className="
-                    absolute
-                    left-1/2
-                    -top-6
-                    -translate-x-1/2
-                    h-14
-                    w-14
-                    rounded-full
-                    flex
-                    items-center
-                    justify-center
-                    shadow-xl
-                    border
-                    border-white/20
-                    backdrop-blur-md
-                "
-                style={{
-                    backgroundColor: isAddPage ? '#9CA3AF' : '#3B82F6',
-                }}
-            >
-                <PlusIcon className="h-7 w-7 text-white" />
-            </Link>
-        </div>
-    );
-}
Index: p/ui/button.tsx
===================================================================
--- app/ui/button.tsx	(revision 5ee2d79a544d8f29386e3bbc597e35433bb9b89a)
+++ 	(revision )
@@ -1,19 +1,0 @@
-import clsx from 'clsx';
-
-interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
-  children: React.ReactNode;
-}
-
-export function Button({ children, className, ...rest }: ButtonProps) {
-  return (
-    <button
-      {...rest}
-      className={clsx(
-        'flex h-10 items-center rounded-lg bg-blue-500 px-4 text-sm font-medium text-white transition-colors hover:bg-blue-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 active:bg-blue-600 aria-disabled:cursor-not-allowed aria-disabled:opacity-50',
-        className,
-      )}
-    >
-      {children}
-    </button>
-  );
-}
Index: p/ui/fonts.ts
===================================================================
--- app/ui/fonts.ts	(revision 5ee2d79a544d8f29386e3bbc597e35433bb9b89a)
+++ 	(revision )
@@ -1,3 +1,0 @@
-import { Poppins } from 'next/font/google';
-
-export const poppins = Poppins({ weight: ['500'], subsets: ['latin'] });
Index: p/ui/global-navigation-indicator.tsx
===================================================================
--- app/ui/global-navigation-indicator.tsx	(revision 5ee2d79a544d8f29386e3bbc597e35433bb9b89a)
+++ 	(revision )
@@ -1,59 +1,0 @@
-'use client';
-
-import { useEffect, useState } from 'react';
-
-const NAVIGATION_START_EVENT = 'fein:navigation-start';
-
-export default function GlobalNavigationIndicator() {
-    const [visible, setVisible] = useState(false);
-
-    useEffect(() => {
-        function onNavigationStart() {
-            setVisible(true);
-        }
-
-        function onDocumentClick(event: MouseEvent) {
-            if (event.defaultPrevented) return;
-            const target = event.target as Element | null;
-            if (!target) return;
-
-            const anchor = target.closest('a[href]') as HTMLAnchorElement | null;
-            if (!anchor) return;
-            if (anchor.target && anchor.target !== '_self') return;
-            if (anchor.hasAttribute('download')) return;
-            if (anchor.getAttribute('href')?.startsWith('#')) return;
-
-            setVisible(true);
-        }
-
-        function onFormSubmit() {
-            setVisible(true);
-        }
-
-        window.addEventListener(NAVIGATION_START_EVENT, onNavigationStart);
-        document.addEventListener('click', onDocumentClick, true);
-        document.addEventListener('submit', onFormSubmit, true);
-
-        return () => {
-            window.removeEventListener(NAVIGATION_START_EVENT, onNavigationStart);
-            document.removeEventListener('click', onDocumentClick, true);
-            document.removeEventListener('submit', onFormSubmit, true);
-        };
-    }, []);
-
-    useEffect(() => {
-        if (!visible) return;
-        const timeoutId = window.setTimeout(() => setVisible(false), 1400);
-        return () => clearTimeout(timeoutId);
-    }, [visible]);
-
-    if (!visible) {
-        return null;
-    }
-
-    return (
-        <div className="pointer-events-none fixed inset-0 z-[95] flex items-center justify-center backdrop-blur-[2px] bg-white/5">
-            <div className="h-10 w-10 animate-spin rounded-full border-2 border-white/30 border-t-white/90 shadow-[0_0_24px_rgba(255,255,255,0.25)]" />
-        </div>
-    );
-}
Index: p/ui/global.css
===================================================================
--- app/ui/global.css	(revision 5ee2d79a544d8f29386e3bbc597e35433bb9b89a)
+++ 	(revision )
@@ -1,57 +1,0 @@
-@import "tailwindcss";
-
-input[type='number'] {
-  -moz-appearance: textfield;
-  appearance: textfield;
-}
-
-input[type='number']::-webkit-inner-spin-button {
-  -webkit-appearance: none;
-  margin: 0;
-}
-
-input[type='number']::-webkit-outer-spin-button {
-  -webkit-appearance: none;
-  margin: 0;
-}
-
-html,
-body {
-  height: 100%;
-  overflow: hidden;
-  overscroll-behavior: none;
-}
-
-::-webkit-scrollbar {
-  background-color: transparent;
-  width: 12px;
-}
-
-::-webkit-scrollbar-track {
-  background-color: transparent;
-}
-
-::-webkit-scrollbar-thumb {
-  border-radius: 20px;
-  border: 3px solid transparent;
-  background-color: rgba(0, 0, 0, 0);
-  background-clip: content-box;
-}
-
-/* For firefox/edge/IE: Hide scrollbars (but keep scrolling) */
-.no-scrollbar {
-  -ms-overflow-style: none;
-  /* IE/Edge */
-  scrollbar-width: none;
-  /* Firefox */
-}
-
-@layer utilities {
-  .bg-fein {
-    background: linear-gradient(180deg, #249e86 0%, #1d7f6a 100%);
-  }
-}
-
-.pb-safe {
-  padding-bottom: env(safe-area-inset-bottom);
-}
Index: p/ui/login-form.tsx
===================================================================
--- app/ui/login-form.tsx	(revision 5ee2d79a544d8f29386e3bbc597e35433bb9b89a)
+++ 	(revision )
@@ -1,136 +1,0 @@
-'use client';
-
-import { poppins } from '@/app/ui/fonts';
-import {
-  AtSymbolIcon,
-  KeyIcon,
-  ExclamationCircleIcon,
-} from '@heroicons/react/24/outline';
-import { Button } from './button';
-import { useActionState } from 'react';
-import { authenticate } from '@/app/(auth)/actions';
-import { useSearchParams } from 'next/navigation';
-import Link from 'next/link';
-
-export default function LoginForm() {
-  const searchParams = useSearchParams();
-  const callbackUrl = searchParams.get('callbackUrl') || '/dashboard';
-  const [errorMessage, formAction, isPending] = useActionState(
-    authenticate,
-    undefined,
-  );
-
-  return (
-    <form
-      action={formAction}
-      className="
-        w-full
-        max-w-sm
-        bg-transparent
-        backdrop-blur-md
-        rounded-2xl
-        px-6
-        py-8
-      "
-    >
-      <div className="space-y-4">
-        {/* Email */}
-        <div className="relative">
-          <input
-            className={`${poppins.className}
-              peer
-              w-full
-              h-12
-              rounded-lg
-              bg-white/30
-              pl-11
-              text-white
-              placeholder:text-white/60
-              focus:outline-none
-              focus:ring-2
-              focus:ring-white/50
-            `}
-            id="email"
-            type="email"
-            name="email"
-            placeholder="Email"
-            required
-          />
-          <AtSymbolIcon className="pointer-events-none absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-white/70" />
-        </div>
-
-        {/* Password */}
-        <div className="relative">
-          <input
-            className={`${poppins.className}
-              peer
-              w-full
-              h-12
-              rounded-lg
-              bg-white/30
-              pl-11
-              text-white
-              placeholder:text-white/60
-              focus:outline-none
-              focus:ring-2
-              focus:ring-white/50
-            `}
-            id="password"
-            type="password"
-            name="password"
-            placeholder="Password"
-            required
-            minLength={6}
-          />
-          <KeyIcon className="pointer-events-none absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-white/70" />
-        </div>
-      </div>
-
-
-      <input type="hidden" name="redirectTo" value={callbackUrl} />
-
-
-      <Button
-        className={`${poppins.className}
-          mt-6
-          w-full
-          h-12
-          rounded-lg
-          bg-[#1d7f6a]
-          hover:bg-[#166655]
-          text-white
-          text-lg
-          font-medium
-          flex 
-          items-center 
-          justify-center
-        `}
-        aria-disabled={isPending}
-      >
-        Log in
-      </Button>
-
-
-      <p
-        className={`${poppins.className} mt-4 text-sm text-center text-white/80 antialiased`}
-      >
-        Don&apos;t have an account?
-        <Link className={`${poppins.className} ml-1 font-medium underline antialiased`} href="/register">
-          Register
-        </Link>
-      </p>
-
-
-      {errorMessage && (
-        <div
-          className="flex h-8 items-end space-x-1"
-          aria-live="polite"
-          aria-atomic="true"
-        >
-          <ExclamationCircleIcon className="h-5 w-5 text-red-500" />
-          <span className="text-sm text-red-500">{errorMessage}</span>
-        </div>
-      )}
-    </form >
-  );
-}
Index: p/ui/register-form.tsx
===================================================================
--- app/ui/register-form.tsx	(revision 5ee2d79a544d8f29386e3bbc597e35433bb9b89a)
+++ 	(revision )
@@ -1,161 +1,0 @@
-'use client';
-
-import { poppins } from '@/app/ui/fonts';
-import {
-  AtSymbolIcon,
-  KeyIcon,
-  UserIcon,
-  ExclamationCircleIcon,
-} from '@heroicons/react/24/outline';
-import { Button } from './button';
-import { useActionState } from 'react';
-import { register } from '@/app/(auth)/actions';
-import { useSearchParams } from 'next/navigation';
-import Link from 'next/link';
-
-export default function RegisterForm() {
-  const searchParams = useSearchParams();
-  const callbackUrl = searchParams.get('callbackUrl') || '/dashboard';
-  const [errorMessage, formAction, isPending] = useActionState(
-    register,
-    undefined,
-  );
-
-  return (
-    <form
-      action={formAction}
-      className="
-        w-full
-        max-w-sm
-        bg-transparent
-        backdrop-blur-md
-        rounded-2xl
-        px-6
-        py-8
-      "
-    >
-      <div className="space-y-4">
-        {/* Name */}
-        <div className="relative">
-          <input
-            className={`${poppins.className}
-              peer
-              w-full
-              h-12
-              rounded-lg
-              bg-white/30
-              pl-11
-              text-white
-              placeholder:text-white/60
-              focus:outline-none
-              focus:ring-2
-              focus:ring-white/50
-            `}
-            type="text"
-            name="user_name"
-            placeholder="Name"
-            required
-          />
-          <UserIcon className="pointer-events-none absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-white/70" />
-        </div>
-
-        {/* Email */}
-        <div className="relative">
-          <input
-            className={`${poppins.className}
-              peer
-              w-full
-              h-12
-              rounded-lg
-              bg-white/30
-              pl-11
-              text-white
-              placeholder:text-white/60
-              focus:outline-none
-              focus:ring-2
-              focus:ring-white/50
-            `}
-            type="email"
-            name="email"
-            placeholder="Email"
-            required
-          />
-          <AtSymbolIcon className="pointer-events-none absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-white/70" />
-        </div>
-
-        {/* Password */}
-        <div className="relative">
-          <input
-            className={`${poppins.className}
-              peer
-              w-full
-              h-12
-              rounded-lg
-              bg-white/30
-              pl-11
-              text-white
-              placeholder:text-white/60
-              focus:outline-none
-              focus:ring-2
-              focus:ring-white/50
-            `}
-            type="password"
-            name="password"
-            placeholder="Password"
-            minLength={6}
-            required
-          />
-          <KeyIcon className="pointer-events-none absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-white/70" />
-        </div>
-      </div>
-
-      <input type="hidden" name="redirectTo" value={callbackUrl} />
-
-      <Button
-        className={`${poppins.className}
-          mt-6
-          w-full
-          h-12
-          rounded-lg
-          bg-[#1d7f6a]
-          hover:bg-[#166655]
-          text-white
-          text-lg
-          font-medium
-          flex
-          items-center
-          justify-center
-        `}
-        aria-disabled={isPending}
-      >
-        Register
-      </Button>
-
-      <p
-        className={`${poppins.className}
-          mt-4
-          text-sm
-          text-center
-          text-white/80
-          antialiased
-        `}
-      >
-        Already have an account?
-        <Link href="/login" className="ml-1 font-medium underline">
-          Log in
-        </Link>
-      </p>
-
-      {errorMessage && (
-        <div
-          className="flex h-8 items-end space-x-1"
-          aria-live="polite"
-          aria-atomic="true"
-        >
-          <ExclamationCircleIcon className="h-5 w-5 text-red-500" />
-          <span className="text-sm text-red-500">{errorMessage}</span>
-        </div>
-      )}
-    </form>
-  );
-}
Index: p/ui/skeletons.tsx
===================================================================
--- app/ui/skeletons.tsx	(revision 5ee2d79a544d8f29386e3bbc597e35433bb9b89a)
+++ 	(revision )
@@ -1,4 +1,0 @@
-// Loading animation
-const shimmer =
-  'before:absolute before:inset-0 before:-translate-x-full before:animate-[shimmer_2s_infinite] before:bg-gradient-to-r before:from-transparent before:via-white/60 before:to-transparent';
-
Index: p/ui/url-search-input.tsx
===================================================================
--- app/ui/url-search-input.tsx	(revision 5ee2d79a544d8f29386e3bbc597e35433bb9b89a)
+++ 	(revision )
@@ -1,57 +1,0 @@
-'use client';
-
-import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
-import { useSearchParams, usePathname, useRouter } from 'next/navigation';
-import { useDebouncedCallback } from 'use-debounce';
-import { poppins } from '@/app/ui/fonts';
-
-const NAVIGATION_START_EVENT = 'fein:navigation-start';
-
-export default function UrlSearchInput({
-    placeholder,
-    queryParam = 'query',
-    debounceMs = 300,
-    resetPageOnChange = false,
-    onNavigateStart,
-}: {
-    placeholder: string;
-    queryParam?: string;
-    debounceMs?: number;
-    resetPageOnChange?: boolean;
-    onNavigateStart?: () => void;
-}) {
-    const searchParams = useSearchParams();
-    const pathname = usePathname();
-    const { replace } = useRouter();
-
-    const handleSearch = useDebouncedCallback((term: string) => {
-        const params = new URLSearchParams(searchParams);
-
-        if (resetPageOnChange) {
-            params.set('page', '1');
-        }
-
-        if (term) {
-            params.set(queryParam, term);
-        } else {
-            params.delete(queryParam);
-        }
-
-        const nextQuery = params.toString();
-        window.dispatchEvent(new Event(NAVIGATION_START_EVENT));
-        onNavigateStart?.();
-        replace(nextQuery ? `${pathname}?${nextQuery}` : pathname, { scroll: false });
-    }, debounceMs);
-
-    return (
-        <div className="relative">
-            <MagnifyingGlassIcon className="pointer-events-none absolute left-3.5 top-1/2 h-5 w-5 -translate-y-1/2 text-white/60" />
-            <input
-                className={`${poppins.className} w-full h-12 rounded-xl bg-white/15 border border-white/15 pl-11 pr-4 text-white placeholder:text-white/50 focus:outline-none focus:ring-2 focus:ring-blue-500/60 focus:border-blue-500/40 transition`}
-                placeholder={placeholder}
-                onChange={(e) => handleSearch(e.target.value)}
-                defaultValue={searchParams.get(queryParam)?.toString()}
-            />
-        </div>
-    );
-}
Index: th.config.ts
===================================================================
--- auth.config.ts	(revision 5ee2d79a544d8f29386e3bbc597e35433bb9b89a)
+++ 	(revision )
@@ -1,49 +1,0 @@
-import type { NextAuthConfig } from 'next-auth';
-
-const AUTH_ROUTES = ['/login', '/register'];
-const PUBLIC_ROUTES = ['/'];
-
-export const authConfig = {
-
-    pages: {
-        signIn: '/login',
-    },
-    callbacks: {
-        async jwt({ token, user }) {
-            if (user) {
-                token.id = user.id;
-            }
-            return token;
-        },
-
-        async session({ session, token }) {
-            if (session.user && token.id) {
-                session.user.id = token.id as string;
-            }
-            return session;
-        },
-
-        authorized({ auth, request: { nextUrl } }) {
-            const isLoggedIn = !!auth?.user;
-            const { pathname } = nextUrl;
-
-            const isAuthRoute = AUTH_ROUTES.some((route) =>
-                pathname.startsWith(route)
-            );
-            const isPublicRoute = PUBLIC_ROUTES.includes(pathname);
-
-            // Not logged in & trying to access protected route
-            if (!isLoggedIn && !isAuthRoute && !isPublicRoute) {
-                return false; // NextAuth will redirect to /login
-            }
-
-            // Logged in & trying to access auth or landing pages
-            if (isLoggedIn && (isAuthRoute || isPublicRoute)) {
-                return Response.redirect(new URL('/dashboard', nextUrl));
-            }
-
-            return true;
-        },
-    },
-    providers: [],
-} satisfies NextAuthConfig;
Index: th.d.ts
===================================================================
--- auth.d.ts	(revision 5ee2d79a544d8f29386e3bbc597e35433bb9b89a)
+++ 	(revision )
@@ -1,13 +1,0 @@
-declare module 'next-auth' {
-    interface Session {
-        user: {
-            id: string; // stringified integer
-            name: string;
-            email: string;
-        };
-    }
-
-    interface JWT {
-        id: string;
-    }
-}
Index: th.ts
===================================================================
--- auth.ts	(revision 5ee2d79a544d8f29386e3bbc597e35433bb9b89a)
+++ 	(revision )
@@ -1,49 +1,0 @@
-import NextAuth from 'next-auth';
-import Credentials from 'next-auth/providers/credentials';
-import { authConfig } from './auth.config';
-import { z } from 'zod';
-import type { User } from '@/app/lib/definitions';
-import bcrypt from 'bcrypt';
-import { sql } from '@/app/lib/db';
-
-async function getUser(email: string): Promise<User | undefined> {
-    try {
-        const user = await sql<User[]>`SELECT * FROM "user" WHERE email=${email}`;
-        return user[0];
-    } catch (error) {
-        console.error('Failed to fetch user:', error);
-        throw new Error('Failed to fetch user.');
-    }
-}
-
-export const { auth, signIn, signOut } = NextAuth({
-    ...authConfig,
-    providers: [
-        Credentials({
-            async authorize(credentials) {
-                const parsedCredentials = z
-                    .object({ email: z.string().email(), password: z.string().min(6) })
-                    .safeParse(credentials);
-
-                if (parsedCredentials.success) {
-                    const { email, password } = parsedCredentials.data;
-
-                    const user = await getUser(email);
-                    if (!user) return null;
-
-                    const passwordsMatch = await bcrypt.compare(password, user.password);
-                    if (passwordsMatch) {
-                        return {
-                            id: String(user.user_id),
-                            name: user.user_name,
-                            email: user.email,
-                        };
-                    }
-                }
-
-                console.log('Invalid credentials');
-                return null;
-            },
-        }),
-    ],
-});
Index: cumentation/FEiN Trac Documentation
===================================================================
--- documentation/FEiN Trac Documentation	(revision 5ee2d79a544d8f29386e3bbc597e35433bb9b89a)
+++ 	(revision )
@@ -1,5027 +1,0 @@
-https://develop.finki.ukim.mk/projects/FEIN/wiki/WikiStart
-= FEiN =
-
-== Краток опис
-Веќе некое време управувам со моите **финансии** во //Google Sheets//, каде што со тек на време направив **//налик-апликација//**, со доста функции за анализа на тие податоци.
-
-Начинот на кој управувам со мојот буџет и финансии е така што секојдневно **//внесувам трансакции//** кои со неколку __полиња__ ги **//полнат/празнат полињата//** кои покажуваат на која **//сметка(трансакциски, кредитни, кеш, девизни)** колку преостанало, колку е потрошено од истата и вкупно//. Исто го променуваат **//графикот на потрошувачка//** и //статистиките колку просечно сум истрошил во месецот, колку последните три дена и колку ми останува просечно да трошам на ден до крај на месецот//. Исто така имам и **//категории//** кои ги доделувам //рачно// на секоја трансакција кои подоцна полнат доделени нивни полиња, собирајќи и кажувајќи колку имам **//потрошено по категорија//**. Тие информации подоцна служат за **//креирање на графици за визуелна анализа на потрошеното//**.
-
-Цел овој систем добро ме служи но станува малце заморно да се внесуваат трансакциите рачно, притоа запазувајќи го форматот кој сам сум си го креирал и пазење да се внесат таговите и датите без синтаксички грешки, кои во почетокот беа подложни на човечки грешки, но остануваат и до ден денес.
-
-Со креирање на **овој систем** сакам да го **решам** првично тој **проблем**, __доведување на човечката грешка на минимум и намалување на времето за внесување на трансакциите__. Подоцна овој систем би овозможил полесно предавање на податоците во модел за машинско учење кој би предвидувал и проектирал информации околу личната потрошувачка...
-
-Системот го замислувам во форма на респонзивна веб апликација(најчесто користена од мобилен телефон) која има централна датабаза до која пристапува секој индивидуален корисник преку неговата сметка.
-
-**FEiN** е име кое на модерен начин го испишува изговорот на зборот Fin кој означува финансирање, но не обично, туку Fine-Tuned, кој збор исто така може да се испише на таков модерен начин. **FEiN** __исто така е и сленг збор во англискиот јазик кој означува голема желба/зависност. Така планирам мојот систем да стане дел од секојдневието, да створи зависност кај луѓето да си го менаџираат буџетот...__
-
-== Изработува
-||=Име и презиме=||=Индекс=||
-||Василаки Тоцили||211101||
- Предмет::
-     Бази на податоци во 2025/2026 зимски семестар[[BR]]
- Под менторство на::
-     проф. д-р Вангел Ајановски
-
-== Фази на изработка
-||=Фаза=||=Име на фаза=||=Статус=||
-||=Ф0=||[wiki:About Дефинирање на проектот]||  [[span(style=color: green, Одобрена )]]  ||
-||=Ф1=||[wiki:ERModel Концептуален дизајн - ЕР Дијаграм и податочни побарувања]||  [[span(style=color: green, Одобрена )]]  ||
-||=Ф2=||[wiki:RelationalDesign Логички и физички дизајн - Креирање база податоци (со SQL DDL)]||  [[span(style=color: green, Одобрена )]]  ||
-||=Ф3=||[wiki:UseCaseModel Апликативен дизајн - Случаи на употреба и сценарија за пристап до базата]||  [[span(style=color: green, Одобрена )]]  ||
-||=Ф4=||[wiki:PrototypeApplication Прототип апликација]||  [[span(style=color: green, Одобрена )]]  ||
-
-||=Milestone 1=||Презентација на прототип ||  [[span(style=color: green, одржана)]]  ||
-
-||=Ф5=||[wiki:Normalization Нормализација]||  [[span(style=color: green, Одобрена )]]  ||
-||=Ф6=||[wiki:AdvancedReports Напредни извештаи од базата (SQL, складирани процедури и релациона алгебра)]||  [[span(style=color: green, Одобрена )]]  ||
-||=Ф7=||[wiki:AdvancedDatabaseDevelopment Напреден развој на датабаза (Тригери, погледи)]||  [[span(style=color: green, Одобрена)]]  ||
-||=Ф8=||[wiki:AdvancedApplicationDevelopment Напреден апликативен развој (Трансакции, Pooling)]||  [[span(style=color: green, Одобрена)]]  ||
-||=Ф9=||[wiki:OtherTopics Other topics (Performance, Security, ...)]||  [[span(style=color: blue, Чека на одобрување)]]  ||
-
-||=Milestone 2=||Презентација на финалната верзија од проектот||  [[span(style=color: red, не е одржана)]]  ||
-
-[wiki:Version1 Верзија 1]
-
-https://develop.finki.ukim.mk/projects/FEIN/wiki/Version1
-= FEiN =
-
-== Краток опис
-Веќе некое време управувам со моите **финансии** во //Google Sheets//, каде што со тек на време направив **//налик-апликација//**, со доста функции за анализа на тие податоци.
-
-Начинот на кој управувам со мојот буџет и финансии е така што секојдневно **//внесувам трансакции//** кои со неколку __полиња__ ги **//полнат/празнат полињата//** кои покажуваат на која **//сметка(трансакциски, кредитни, кеш, девизни)** колку преостанало, колку е потрошено од истата и вкупно//. Исто го променуваат **//графикот на потрошувачка//** и //статистиките колку просечно сум истрошил во месецот, колку последните три дена и колку ми останува просечно да трошам на ден до крај на месецот//. Исто така имам и **//категории//** кои ги доделувам //рачно// на секоја трансакција кои подоцна полнат доделени нивни полиња, собирајќи и кажувајќи колку имам **//потрошено по категорија//**. Тие информации подоцна служат за **//креирање на графици за визуелна анализа на потрошеното//**.
-
-Цел овој систем добро ме служи но станува малце заморно да се внесуваат трансакциите рачно, притоа запазувајќи го форматот кој сам сум си го креирал и пазење да се внесат таговите и датите без синтаксички грешки, кои во почетокот беа подложни на човечки грешки, но остануваат и до ден денес.
-
-Со креирање на **овој систем** сакам да го **решам** првично тој **проблем**, __доведување на човечката грешка на минимум и намалување на времето за внесување на трансакциите__. Подоцна овој систем би овозможил полесно предавање на податоците во модел за машинско учење кој би предвидувал и проектирал информации околу личната потрошувачка...
-
-Системот го замислувам во форма на мобилна апликација која има централна датабаза до која пристапува секој индивидуален корисник преку неговата сметка.
-
-**FEiN** е име кое на модерен начин го испишува изговорот на зборот Fin кој означува финансирање, но не обично, туку Fine-Tuned, кој збор исто така може да се испише на таков модерен начин. **FEiN** __исто така е и сленг збор во англискиот јазик кој означува голема желба/зависност. Така планирам мојот систем да стане дел од секојдневието, да створи зависност кај луѓето да си го менаџираат буџетот...__
-
-== Изработува
-||=Име и презиме=||=Индекс=||
-||Василаки Тоцили||211101||
- Предмет::
-     Бази на податоци во 2024/2025 зимски семестар[[BR]]
- Под менторство на::
-     проф. д-р Вангел Ајановски
-
-== Фази на изработка
-||=Фаза=||=Име на фаза=||=Статус=||
-||=Ф0=||[wiki:About Дефинирање на проектот]||  [[span(style=color: green, Одобрена )]]  ||
-||=Ф1=||[wiki:ERModel Концептуален дизајн - ЕР Дијаграм и податочни побарувања]||  [[span(style=color: green, Одобрена )]]  ||
-||=Ф2=||[wiki:RelationalDesign Логички и физички дизајн - Креирање база податоци (со SQL DDL)]||  [[span(style=color: green, Одобрена )]]  ||
-||=Ф3=||[wiki:UseCaseModel Апликативен дизајн - Случаи на употреба и Сценарија за пристап до базата - SQL Погледи]||  [[span(style=color: green, Одобрена )]]  ||
-||=Ф4=||[wiki:AdvancedReports Напредни извештаи од базата (SQL и складирани процедури)]||  [[span(style=color: green, Одобрена )]]  ||
-||=Ф5=||[wiki:PrototypeApplication Прототип апликација - Основни функционалности]||  [[span(style=color: green, Одобрена )]]  ||
-||=Ф6=||[wiki:Normalization Нормализација и подобрување на дизајнот]||  [[span(style=color: orange, Започната)]]  ||
-||=Ф7=||[wiki:AditionalApplicationDevelopment Напреден апликативен развој]||  [[span(style=color: orange, Започната)]]  ||
-
-
-https://develop.finki.ukim.mk/projects/FEIN/wiki/About
-= FEiN =
-
-== Членови на тимот
-
-||=Име и презиме=||=Индекс=||
-||  Василаки Тоцили   ||  211101  ||
-
-== Краток опис на проектот
-
-**FEiN** е респонзивна веб апликација(најчесто користена од мобилен телефон) која овозможува едноставно и ефективно **//управување со личните финансии//**.
-
-Корисниците **//внесуваат дневни трансакции//**, кои автоматски се пресметуваат и //распределуваат по сметки (трансакциски, кредитни, кеш, девизни)//, **//давајќи увид во преостанатите средства и потрошеното//**. Секоја трансакција може да се //**категоризира**// за понапредна анализа, овозможувајќи **//графички приказ//** на трошењето по категории. Апликацијата **//генерира статистики//** за **//месечна потрошувачка, дневни трошоци, како и проекции за идно трошење**//.
-
-**FEiN** користи **централна база** на податоци, овозможувајќи сигурност и конзистентност на податоците за сите корисници. Со **FEiN**, наместо со //Excel-базирано следење//, времето за внесување на податоци се намалува, а шансите за човечки грешки се сведуваат на минимум. Во иднина, **FEiN** би овозможил интеграција со модели за машинско учење за предвидување и оптимизирање на личниот буџет.
-
-== Детален опис на проектот
-* **За кого е наменет проектот односно кој според вашата замисла ќе биде одговорен да ја поседува базата на апликацијата и да ја обезбедува како сервис - дали е тоа фирма/институација/организација или е поединец и краток опис за нивните потреби?**
-	Проектот е наменет за //индивидуални корисници// кои сакаат да управуваат со своите лични финансии на едноставен и интуитивен начин. Корисниците креираат //сметки// со кои пристапуваат до //централна база// на податоци преку апликацијата. Базата ќе биде администрирана централизирано од тимот што стои зад //FEiN//, со цел обезбедување на сигурност, конзистентност и лесен пристап од било кој уред.
-* **Какви проблеми во фирмата или организацијата ќе решавате?**
-    //FEiN// ги решава следните проблеми поврзани со управување на личните финансии:
-    1. //Осигурување на точност при внесување податоци//: За разлика од //Excel//, каде што форматните грешки и пропусти се чести, //FEiN// ќе обезбеди //стандардизиран начин за внесување// на трансакции. Ова ќе ја намали можноста за грешки и ќе им олесни на корисниците во следењето на нивниот буџет.
-    2. //Подобрено корисничко искуство//: Апликацијата ќе понуди интуитивен и лесен за користење интерфејс, кој ќе го замени комплексното и визуелно ограничено искуство на //Excel//.
-    3. //Автоматски пресметки и визуализации//: //FEiN// автоматски ќе ги пресметува вкупните потрошени и преостанати средства, ќе ги категоризира трошоците и ќе прикажува визуелизации за полесна анализа на финансиските податоци.
-    4. //Пристап до детални статистики без дополнителни трошоци//: //За разлика од други апликации//, //FEiN// ги нуди сите статистики бесплатно, овозможувајќи длабински увид во финансиските навики.
-- **Какви видови корисници ќе има апликацијата - во смисла на типови вработени во фирмата/институцијата или клиенти?**
-	//FEiN// е наменета исклучиво за //индивидуални корисници//. Во почетната фаза, сите корисници ќе имаат //бесплатен пристап// до апликацијата и сите нејзини функционалности. //Нема// да има различни типови на //кориснички профили// или //премиум функции// во првата верзија.
-- **Ако има други слични проекти во фирмата или во околината, или веќе постојат готови решенија во светот, во што е различен вашиот предлог проект.**
-	FEiN се издвојува по следното:
-	1. //Над Excel-базираните системи//: Решавајќи ги проблемите со рачно внесување и форматни грешки, FEiN обезбедува структурирано и автоматизирано управување со личните финансии, во значително подобрено корисничко искуство.
-	2. //Конкуренција со слични апликации//: За разлика од многу слични апликации кои нудат напредни статистики и прогнози како премиум функции, FEiN ги нуди истите //бесплатно// и со //отворен код// од самиот почеток.
-- **Дали се работи за веб, мобилна и/или десктоп апликација?**
-	FEiN е планирана како //респонзивна веб апликација(најчесто користена од мобилен телефон)//, со фокус на достапност и практичност за корисниците, кои можат лесно да внесуваат трансакции и да ги следат своите финансии од својот паметен телефон.
-
-[wiki:AboutVersion1 Верзија 1]
-
-https://develop.finki.ukim.mk/projects/FEIN/wiki/AboutVersion1
-= FEiN =
-
-== Членови на тимот
-
-||=Име и презиме=||=Индекс=||
-||  Василаки Тоцили   ||  211101  ||
-
-== Краток опис на проектот
-
-**FEiN** е мобилна апликација која овозможува едноставно и ефективно **//управување со личните финансии//**.
-
-Корисниците **//внесуваат дневни трансакции//**, кои автоматски се пресметуваат и //распределуваат по сметки (трансакциски, кредитни, кеш, девизни)//, **//давајќи увид во преостанатите средства и потрошеното//**. Секоја трансакција може да се //**категоризира**// за понапредна анализа, овозможувајќи **//графички приказ//** на трошењето по категории. Апликацијата **//генерира статистики//** за **//месечна потрошувачка, дневни трошоци, како и проекции за идно трошење**//.
-
-**FEiN** користи **централна база** на податоци, овозможувајќи сигурност и конзистентност на податоците за сите корисници. Со **FEiN**, наместо со //Excel-базирано следење//, времето за внесување на податоци се намалува, а шансите за човечки грешки се сведуваат на минимум. Во иднина, **FEiN** би овозможил интеграција со модели за машинско учење за предвидување и оптимизирање на личниот буџет.
-
-== Детален опис на проектот
-* **За кого е наменет проектот односно кој според вашата замисла ќе биде одговорен да ја поседува базата на апликацијата и да ја обезбедува како сервис - дали е тоа фирма/институација/организација или е поединец и краток опис за нивните потреби?**
-	Проектот е наменет за //индивидуални корисници// кои сакаат да управуваат со своите лични финансии на едноставен и интуитивен начин. Корисниците креираат //сметки// со кои пристапуваат до //централна база// на податоци преку апликацијата. Базата ќе биде администрирана централизирано од тимот што стои зад //FEiN//, со цел обезбедување на сигурност, конзистентност и лесен пристап од било кој уред.
-* **Какви проблеми во фирмата или организацијата ќе решавате?**
-    //FEiN// ги решава следните проблеми поврзани со управување на личните финансии:
-    1. //Осигурување на точност при внесување податоци//: За разлика од //Excel//, каде што форматните грешки и пропусти се чести, //FEiN// ќе обезбеди //стандардизиран начин за внесување// на трансакции. Ова ќе ја намали можноста за грешки и ќе им олесни на корисниците во следењето на нивниот буџет.
-    2. //Подобрено корисничко искуство//: Апликацијата ќе понуди интуитивен и лесен за користење интерфејс, кој ќе го замени комплексното и визуелно ограничено искуство на //Excel//.
-    3. //Автоматски пресметки и визуализации//: //FEiN// автоматски ќе ги пресметува вкупните потрошени и преостанати средства, ќе ги категоризира трошоците и ќе прикажува визуелизации за полесна анализа на финансиските податоци.
-    4. //Пристап до детални статистики без дополнителни трошоци//: //За разлика од други апликации//, //FEiN// ги нуди сите статистики бесплатно, овозможувајќи длабински увид во финансиските навики.
-- **Какви видови корисници ќе има апликацијата - во смисла на типови вработени во фирмата/институцијата или клиенти?**
-	//FEiN// е наменета исклучиво за //индивидуални корисници//. Во почетната фаза, сите корисници ќе имаат //бесплатен пристап// до апликацијата и сите нејзини функционалности. //Нема// да има различни типови на //кориснички профили// или //премиум функции// во првата верзија.
-- **Ако има други слични проекти во фирмата или во околината, или веќе постојат готови решенија во светот, во што е различен вашиот предлог проект.**
-	FEiN се издвојува по следното:
-	1. //Над Excel-базираните системи//: Решавајќи ги проблемите со рачно внесување и форматни грешки, FEiN обезбедува структурирано и автоматизирано управување со личните финансии, во значително подобрено корисничко искуство.
-	2. //Конкуренција со слични апликации//: За разлика од многу слични апликации кои нудат напредни статистики и прогнози како премиум функции, FEiN ги нуди истите //бесплатно// и со //отворен код// од самиот почеток.
-- **Дали се работи за веб, мобилна и/или десктоп апликација?**
-	FEiN е планирана како //мобилна апликација//, со фокус на достапност и практичност за корисниците, кои можат лесно да внесуваат трансакции и да ги следат своите финансии од својот паметен телефон.
-
-https://develop.finki.ukim.mk/projects/FEIN/wiki/ERModel
-= Верзија 3
-
-== ER дијаграм
-[[Image(ERDiagramVer3.png)]]
-
-== Податочни побарувања
-
-=== Ентитети
-1. **User** - ентитет за корисникот кој се логира
-	-       user_id - SERIAL (примарен клуч)
-	- user_name - VARCHAR(30) (атрибут)
-	-         email - VARCHAR(30) (атрибут)
-	-   password - VARCHAR(30) (атрибут)
-2. **Transaction Account** - секој корисник има една или повеќе трансакциски акаунти/сметки(кредитна картичка, дебитна картичка, кеш, девизи...)
-	- transaction_account_id - SERIAL (примарен клуч)
-	-                          user_id - INT (надворешен клуч)
-	-              account_name - VARCHAR(35) (атрибут)
-	-                         balance - DECIMAL(10, 2) (атрибут)
-3. **Transaction Breakdown** - за секоја трансакција(акција на плаќање/добивање пари) да можe да менува различни акаунти(сте платиле пола со картица, пола со кеш) и секоја трансакција да биде посложена(ако платите вие за повеќе луѓе, и очекувате да бидете отплатени од нив) потребен е ваков механизам во вид на ентитет кој ќе го овозможи тоа
-	- transaction_breakdown_id - SERIAL (примарен клуч) 
-	-                    transaction_id - INT (надворешен клуч)
-	-      transaction_account_id - INT (надворешен клуч)
-	-                    spent_amount - DECIMAL(10, 2) (атрибут)
-	-                  earned_amount - DECIMAL(10, 2) (атрибут)
-4. **Transaction** - трансакција, акција на трансфер на пари. Атрибутите amount и net_amount се потребни во случајот за сложени трансакции, кога пример вие плаќате на работа за јадењето на сите колеги во апликација за достава на храна и потоа колегите си го исплаќаат својот дел дали преку кеш директно вам или преку електронски трансфер со е-банкарство, вкупниот износ на целата нарачка кој го плаќате ќе се запише во amount, а додека со тригери ќе се ажурира net_amount со збир на сите вредности од атрибутите на помошните трансакции(earned_amount+spent_amount). Тие ќе бидат истата вредност доколку вие со трансакцијата го плаќате целиот износ за вас или пак добивате пари кои не треба да ги вратите
-	-      transaction_id - SERIAL (примарен клуч)
-	-                   tag_id - INT (надворешен клуч)
-	- transaction_name - VARCHAR(100) (атрибут)
-        -                  date - TIMESTAMPZ (атрибут)
-	-                 amount - DECIMAL(10, 2) (атрибут)
-	-           net_amount - DECIMAL(10, 2) (атрибут)
-5.  **Tag** - помошни групи(тагови) со кои може да се класифицираат трансакциите, а со тоа да се изведуваат корисни статистики
-	-       tag_id - SERIAL (примарен клуч)
-	- tag_name - VARCHAR(50) (атрибут)
-
-
-== Релации
-- **!TransactionHasTransactionBreakdown** - 1:N една трансакција може да има повеќе помошни трансакции
-- **!TagAssignedToTransaction** - N:N повеќе тагови можат да се доделат на повеќе трансакции
-- **!TransactionBreakdownModifiesTransactionAccount** - 1:N една сметка може да биде менувана од повеќе помошни трансакции
-- **!UserHasTransactionAccount** - 1:N еден корисник може да има повеќе сметки
-
-== Историјат
-[wiki:ERModelVer1 Верзија 1] \\
-[wiki:ERModelVer2 Верзија 2] \\
-[wiki:ERModelVer3 Верзија 3]
-
-
-https://develop.finki.ukim.mk/projects/FEIN/wiki/ERModelVer2
-= Верзија 2
-
-== ER дијаграм
-[[Image(wiki:ERModel:ERDiagramVer2.png)]]
-
-== Податочни побарувања
-
-=== Ентитети
-1. **User** - ентитет за корисникот кој се логира
-	-       user_id - SERIAL (примарен клуч)
-	- user_name - VARCHAR(30) (атрибут)
-	-         email - VARCHAR(30) (атрибут)
-	-   password - VARCHAR(30) (атрибут)
-2. **Transaction Account**
-	- transaction_account_id - SERIAL (примарен клуч)
-	-                          user_id - INT (надворешен клуч)
-	-              account_name - VARCHAR(35) (атрибут)
-	-                         balance - DECIMAL(10, 2) (атрибут)
-3. **Transaction Breakdown**
-	- transaction_breakdown_id - SERIAL (примарен клуч) 
-	-                    transaction_id - INT (надворешен клуч)
-	-      transaction_account_id - INT (надворешен клуч)
-	-                    spent_amount - DECIMAL(10, 2) (атрибут)
-	-                  earned_amount - DECIMAL(10, 2) (атрибут)
-4. **Transaction**
-	-      transaction_id - SERIAL (примарен клуч)
-	-                   tag_id - INT (надворешен клуч)
-	- transaction_name - VARCHAR(100) (атрибут)
-        -                  date - DATE (атрибут)
-	-                 amount - DECIMAL(10, 2) (атрибут)
-	-           net_amount - DECIMAL(10, 2) (атрибут)
-5.  **Tag**
-	-       tag_id - SERIAL (примарен клуч)
-	- tag_name - VARCHAR(50) (атрибут)
-
-
-== Релации
-- **!TransactionHasTransactionBreakdown** - 1:N една трансакција може да има повеќе помошни трансакции
-- **!TagAssignedToTransaction** - N:N повеќе тагови можат да се доделат на повеќе трансакции
-- **!TransactionBreakdownModifiesTransactionAccount** - 1:1 една помошна трансакција менува една сметка
-- **!UserHasTransactionAccount** - 1:N еден корисник може да има повеќе сметки
-
-== Историјат
-[wiki:ERModelVer1 Верзија 1] \\
-[wiki:ERModelVer2 Верзија 2]
-
-https://develop.finki.ukim.mk/projects/FEIN/wiki/ERModelVer1
-= Верзија 1
-
-== ER дијаграм
-[[Image(wiki:ERModel:ERDiagramVer1.png)]]
-
-== Податочни побарувања
-
-=== Ентитети
-1. **User** - ентитет за корисникот кој се логира
-	-       user_id - SERIAL (примарен клуч)
-	- user_name - VARCHAR(30) (атрибут)
-	-         email - VARCHAR(30) (атрибут)
-	-   password - VARCHAR(30) (атрибут)
-2. **Transaction Account**
-	- transaction_account_id - SERIAL (примарен клуч)
-	-                          user_id - INT (надворешен клуч)
-	-              account_name - VARCHAR(35) (атрибут)
-	-                         balance - DECIMAL(10, 2) (атрибут)
-3. **Transaction Breakdown**
-	- transaction_breakdown_id - SERIAL (примарен клуч) 
-	-                    transaction_id - INT (надворешен клуч)
-	-      transaction_account_id - INT (надворешен клуч)
-	-                    spent_amount - DECIMAL(10, 2) (атрибут)
-	-                  earned_amount - DECIMAL(10, 2) (атрибут)
-4. **Transaction**
-	-      transaction_id - SERIAL (примарен клуч)
-	-                  day_id - INT (надворешен клуч)
-	-                   tag_id - INT (надворешен клуч)
-	- transaction_name - VARCHAR(100) (атрибут)
-	-                 amount - DECIMAL(10, 2) (атрибут)
-	-           net_amount - DECIMAL(10, 2) (атрибут)
-5.  **Tag**
-	-       tag_id - SERIAL (примарен клуч)
-	- tag_name - VARCHAR(50) (атрибут)
-6. **Day**
-	-           day_id - SERIAL (примарен клуч)
-	-      month_id - INT (надворешен клуч)
-	-     day_name - VARCHAR(9) (атрибут)
-	- day_number - INT (атрибут)
-7. **Month**
-	-       month_id - SERIAL (примарен клуч)
-	-           year_id - INT (надворешен клуч)
-	- month_name - VARCHAR(20) (атрибут)
-8. **Year**
-	-       year_id - SERIAL (примарен клуч)
-	- year_name - INT (атрибут)
-
-== Релации
-- **has** - 1:N една година има повеќе месеци
-- **has** - 1:N еден месец има повеќе денови
-- **has** - 1:N еден ден може да има повеќе трансакции
-- **has** - 1:N една трансакција може да има повеќе помошни трансакции
-- **assigned** - N:N повеќе тагови можат да се доделат на повеќе трансакции
-- **modifies** - 1:1 една помошна трансакција менува една сметка
-- **has** - 1:N еден корисник може да има повеќе сметки
-
-== Историјат
-[wiki:ERModelVer1 Верзија 1]
-
-https://develop.finki.ukim.mk/projects/FEIN/wiki/RelationalDesign
-= Логички и физички дизајн
-
-== Релациска шема (со мапирачка трансформација)
-=== Ознаки
-- примарни клучеви     - __**болдирани и подвлечени__**
-- not null атрибути        - **болдирани**
-- надворешни клучеви - * до името на надворешниот клуч и во заграда табелата кон која референцира
-- останати атрибути      - без ознака
-=== Табели
-- USER (**__user_id__**, **user_name**, **email**, **password**)
-- TRANSACTION_ACCOUNT (__**transaction_account_id**__, account_name, balance, user_id* (USER))
-- TRANSACTION (__**transaction_id__**, transaction_name, **amount**, net_amount, **date**)
-- TRANSACTION_BREAKDOWN (__**transaction_breakdown_id__**, transaction_id* (TRANSACTION), transaction_account_id* (TRANSACTION_ACCOUNT), spent_amount, earned_amount)
-- TAG (__**tag_id__**, **tag_name**)
-- TAG_ASSIGNED_TO_TRANSACTION(__**tag_assigned_to_transaction_id**__, **tag_id*** (TAG), **transaction_id*** (TRANSACTION))
-
-=== DDL скрипта за креирање и бришење на табелите
-[wiki:ddlScript.sql DDL скрипта]
-=== DML скрипта за полнење на табелите со податоци
-[wiki:dmlScript.sql DML скрипта]
-=== Релациски дијаграм изваден од DBeaver
-[[Image(DBeaverExportVer2.png)]]
-
-https://develop.finki.ukim.mk/projects/FEIN/wiki/ddlScript.sql
-{{{#!sql
--- Delete tables if they exist
-DROP TABLE IF EXISTS transaction_breakdown CASCADE;
-DROP TABLE IF EXISTS transaction CASCADE;
-DROP TABLE IF EXISTS transaction_account CASCADE;
-DROP TABLE IF EXISTS tag CASCADE;
-DROP TABLE IF EXISTS "user" CASCADE;
-DROP TABLE IF EXISTS tag_assigned_to_transaction CASCADE;
-
--- Create USER table
-CREATE TABLE "user" (
-    user_id SERIAL PRIMARY KEY,
-    user_name VARCHAR(50) NOT NULL,
-    email VARCHAR(100) NOT NULL,
-    password VARCHAR(100) NOT NULL
-);
-
--- Create TRANSACTION_ACCOUNT table
-CREATE TABLE transaction_account (
-    transaction_account_id SERIAL PRIMARY KEY,
-    account_name VARCHAR(50),
-    balance DECIMAL(10, 2),
-    user_id INT REFERENCES "user"(user_id)
-);
-
--- Create TRANSACTION table
-CREATE TABLE transaction (
-    transaction_id SERIAL PRIMARY KEY,
-    transaction_name VARCHAR(100),
-    amount DECIMAL(10, 2) NOT NULL,
-    net_amount DECIMAL(10, 2),
-    date TIMESTAMPTZ NOT NULL
-);
-
--- Create TAG table
-CREATE TABLE tag (
-    tag_id SERIAL PRIMARY KEY,
-    tag_name VARCHAR(50) NOT NULL
-);
-
--- Create TAG_ASSIGNED_TO_TRANSACTION table
-CREATE TABLE tag_assigned_to_transaction (
-    tag_assigned_to_transaction_id SERIAL PRIMARY KEY,
-    transaction_id INT NOT NULL REFERENCES transaction(transaction_id) ON DELETE CASCADE,
-    tag_id INT NOT NULL REFERENCES tag(tag_id) ON DELETE CASCADE
-);
-
--- Create TRANSACTION_BREAKDOWN table
-CREATE TABLE transaction_breakdown (
-    transaction_breakdown_id SERIAL PRIMARY KEY,
-    transaction_id INT REFERENCES transaction(transaction_id),
-    transaction_account_id INT REFERENCES transaction_account(transaction_account_id),
-    spent_amount DECIMAL(10, 2),
-    earned_amount DECIMAL(10, 2)
-);
-}}}
-
-https://develop.finki.ukim.mk/projects/FEIN/wiki/dmlScript.sql
-{{{#!sql
-INSERT INTO "user" (user_name, email, password) VALUES
-('John Doe', 'john@example.com', 'password123'),
-('Jane Smith', 'jane@example.com', 'securepass'),
-('Alice Johnson', 'alice@example.com', 'alicepwd');
-
-INSERT INTO tag (tag_name) VALUES
-('Food'),
-('Work'),
-('Groceries'),
-('Entertainment'),
-('Bills');
-
-INSERT INTO transaction_account (account_name, balance, user_id) VALUES
-('Cash', 5000.00, 1),
-('Bank Account', 20000.00, 1),
-('Credit Card', -1000.00, 2),
-('Savings', 15000.00, 2),
-('Foreign Currency', 300.00, 3);
-
-INSERT INTO transaction (transaction_name, amount, net_amount, date) VALUES 
-('Bought groceries', 1000.00, 800.00, '2024-11-23 13:51:35+02:00'),
-('Paid rent', 15000.00, 15000.00, '2024-11-01 17:01:41+02:00'),
-('Dinner with friends', 2500.00, 1500.00, '2024-11-29 22:02:22+02:00'),
-('Movie tickets', 600.00, 600.00, '2024-11-30 17:38:00+02:00'),
-('Electricity bill', 1200.00, 1200.00, '2024-11-02 07:03:35+02:00');
-
-INSERT INTO tag_assigned_to_transaction (transaction_id, tag_id) VALUES 
-(1, 1),
-(2, 5),
-(3, 2),
-(4, 4),
-(5, 5);
-
-INSERT INTO transaction_breakdown (transaction_id, transaction_account_id, spent_amount, earned_amount) VALUES
-(1, 1, 1000.00, 200.00),  -- Spent 1000 on Cash, earned back 200
-(1, 2, 0.00, 800.00),     -- Earned back 800 on Bank Account
-(2, 2, 15000.00, 0.00),   -- Spent 15000 on Bank Account for rent
-(3, 1, 2500.00, 1000.00), -- Spent 2500 on Cash, earned back 1000
-(4, 3, 600.00, 0.00);     -- Spent 600 on Credit Card for movie
-}}}
-
-https://develop.finki.ukim.mk/projects/FEIN/wiki/UseCaseModel
-== Апликативен дизајн - Случаи на употреба и Сценарија за пристап до базата - SQL Погледи
-
-== Актери
-**Ненајавен корисник**
-- Не може да управува со апликацијата пред да се најави бидејќи нема да има база на податоци со која може да управува
-**Најавен корисник**
-- Корисникот креира сметка за пристап до апликацијата за да управува со личните финансии. Може да:
- - Додава трансакциски сметки (на пр. готовина, кредитна картичка, заштеда)
- - Внесува дневни трансакции со тагови, имиња, износи и датуми
- - Прегледува извештаи за месечни/годишни трошоци и распоред на дневни трансакции
-
-== Сценарија
-
-- [wiki:RegistracijaKorisnik Регистрација на корисник]
-- [wiki:NajavaKorisnik Најава на корисник]
-- [wiki:DodavanjeTransakciska Додавање на трансакциска сметка]
-- [wiki:PregledSmetki Преглед на сметки]
-- [wiki:DodavanjeTransakcija Додавање на трансакција]
-- [wiki:PregledNaTransakcii Преглед на трансакции]
-- [wiki:AzuriranjeBrisenjeTransakcija Ажурирање/бришење трансакции]
-- [wiki:Izvestai Извештаи (годишни/месечни/по тагови/графици)]
-
-== Најважни случаи на употреба
-
-- [wiki:RegistracijaKorisnik Регистрација на корисник]
-- [wiki:DodavanjeTransakciska Додавање на трансакциска сметка]
-- [wiki:DodavanjeTransakcija Додавање на трансакција]
-- [wiki:AzuriranjeBrisenjeTransakcija Ажурирање/бришење трансакции]
-- [wiki:Izvestai Извештаи (годишни/месечни/по тагови/графици)]
-
-https://develop.finki.ukim.mk/projects/FEIN/wiki/RegistracijaKorisnik
-== Регистрација на корисник
-
-==== Актери: **Ненајавен корисник**
-
-1. Корисникот притиска на копчето за регистрација
-2. Се појавува форма за регистрација
-3. Корисникот ги внесува своето име, емаил и лозинка во апликацијата.
-4. Апликацијата внесува нов запис во табелата {{{user}}}
-{{{#!sql
-INSERT INTO user (user_name, email, password)
-VALUES ('Јован Петров', 'jovan.petrovski@example.com', 'hashed_password');
-}}}
-5. По успешно креирање на сметка тој може да се најави на системот
-
-https://develop.finki.ukim.mk/projects/FEIN/wiki/NajavaKorisnik
-== Најава на корисник
-
-==== Актери: **Ненајавен корисник**
-
-1. Корисникот притиска на копчето за најава
-2. Се појавува форма за најава
-3. Корисникот ги внесува своите емаил и лозинка во полето за најава
-4. Апликацијата проверува дали емаилот и лозинката постојат во табелата {{{user}}}
-{{{#!sql
-SELECT user_id 
-FROM user 
-WHERE email = 'jovan.petrovski@example.com' AND password = 'hashed_password';
-}}}
-5. Ако внесените податоци се точни:
- - Корисникот е пренасочен кон почетната страница на апликацијата и добива пристап до функционалностите
-===== Алтернативен тек:
-6. Ако внесените податоци не се точни:
- - Апликацијата прикажува порака за грешка: „Невалидно корисничко име или лозинка“
-
-
-https://develop.finki.ukim.mk/projects/FEIN/wiki/DodavanjeTransakciska
-== Додавање на трансакциска сметка
-
-==== Актери: **Најавен корисник**
-
-1. Корисникот притиска на копчето „Додај сметка“
-2. Се појавува форма за внес на информации за трансакциската сметка
-3. Корисникот ги внесува следниве податоци:
- - Име на сметка (на пр. „Трансакциска сметка НЛБ“)
- - Почетен баланс (на пр. „50000 МКД“)
-4. Апликацијата внесува нов запис во табелата {{{transaction_account}}} поврзан со тековниот корисник.
-{{{#!sql
-INSERT INTO transaction_account (account_name, balance, user_id)
-VALUES ('Трансакциска сметка НЛБ', 50000, 101);
-}}}
-5. По успешно додавање на сметката, корисникот добива известување:
- - „Сметката е успешно додадена!“
-===== Алтернативен тек
-6. Корисникот не ги внел сите потребни податоци (на пр. име на сметката е празно):
- - Апликацијата прикажува порака: „Ве молиме внесете валидни податоци за сметката“
-
-https://develop.finki.ukim.mk/projects/FEIN/wiki/PregledSmetki
-== Преглед на сметки
-
-==== Актери: **Најавен корисник**
-
-Откако ќе се додаде сметката, корисникот може да ги види сите свои сметки на следниов начин:
-1. Корисникот притиска на табот „Мои сметки“ од менито во апликацијата.
-2. Апликацијата испраќа SQL прашање до базата за да ги пронајде сите трансакциски сметки поврзани со корисникот
-{{{#!sql
-SELECT account_name, balance 
-FROM transaction_account 
-WHERE user_id = 101;
-}}}
-3. Апликацијата ги добива податоците од базата и ги прикажува во листа, со следниве информации за секоја сметка:
- - Име на сметката
- - Тековен баланс
-4. Корисникот може да избере една од сметките за дополнителни опции:
- - Преглед на трансакции
- - Уредување на сметката
- - Бришење на сметката
-
-===== Алтернативен тек
-5. Корисникот нема додадено сметки, апликацијата прикажува порака:
- - „Немате додадено трансакциски сметки. Притиснете на копчето ‘Додај сметка’ за да започнете“
-
-https://develop.finki.ukim.mk/projects/FEIN/wiki/DodavanjeTransakcija
-== Додавање трансакција
-
-==== Актери: **Најавен корисник**
-1. Корисникот притиска на копчето за додавање на трансакции
-2. Се отвора форма каде корисникот ги внесува следните детали за трансакцијата:
- - Дали трансакцијата додава или одзема од сметка (задолжително поле)
- - Име на трансакција (задолжително поле)
- - Износ на цела трансакција (задолжително поле)
- - Датум на трансакцијата (опционално, доколку не се внесе се зема моменталното време)
- - Таг за категоризација (опционално, но препорачливо)
- - Сметка на која трансакцијата се поврзува (се бира од достапните)
-3. Корисникот може да додаде дополнителни детали преку динамично додавање на полиња со клик на копче за додавање кои ќе бидат зачувани како {{{earned_amount}}} и {{{spent_amount}}} во табелата {{{transaction_breakdown}}}
-4. Апликацијата ги валидира внесените податоци (на пример, проверува дали сите задолжителни полиња се пополнети и дали износот е валиден број)
-5. Апликацијата внесува нов запис во табелите {{{transaction}}} и {{{tag_assigned_to_transaction}}}:
-{{{#!sql
-INSERT INTO transaction (transaction_name, amount, net_amount, date)
-VALUES ('Пазарување', 3500.00, 0, '2024-11-23 13:51:35+02:00');
-RETURNING transaction_id;
-}}}
-{{{#!sql
-INSERT INTO tag_assigned_to_transaction (transaction_id, tag_id)
-VALUES (1, 3);
-RETURNING tag_assigned_to_transaction_id;
-}}}
-6. Трансакцијата тригерира креирање запис во {{{transaction_breakdown}}} кој исто така ја ажурира сметката и пресметува {{{net_amount}}} преку тригер:
-- Во зависност од тоа дали трансакцијата додава или одзема се прави додавање во соодветното поле на {{{transaction_breakdown}}}:
- - За додавање на сметка:
-{{{#!sql
-INSERT INTO transaction_breakdown (transaction_id, transaction_account_id, earned_amount, spent_amount)
-VALUES (1, 2, 3500, 0);
-}}}
- - За одземање од сметка:
-{{{#!sql
-INSERT INTO transaction_breakdown (transaction_id, transaction_account_id, earned_amount, spent_amount)
-VALUES (1, 2, 0, 3500);
-}}}
- - На крај се ажурира балансот во {{{transaction_account}}}
-{{{#!sql
-UPDATE transaction_account:
-SET balance = balance - 3500
-WHERE transaction_account_id = 2;
-}}}
-7. Ако корисникот додал дополнителни детали преку динамично додавање на полиња(на пример, {{{earned_amount}}} и {{{spent_amount}}}), апликацијата внесува соодветни записи во табелата {{{transaction_breakdown}}}:
-{{{#!sql
-INSERT INTO transaction_breakdown (transaction_id, transaction_account_id, earned_amount, spent_amount)
-VALUES (1, 2, 0, 1500),
-       (1, 3, 200, 0);
-}}}
-8. Тригер во базата:
- - Автоматски ја пресметува и ажурира вредноста на {{{net_amount}}} во табелата {{{transaction}}} според деталите внесени во табелата {{{transaction_breakdown}}}
-9. По успешното додавање, тригерот:
- - Го ажурира балансот на поврзаната трансакциска сметка во табелата {{{transaction_account}}}:
-{{{#!sql
-UPDATE transaction_account
-SET balance = 
-    CASE 
-        WHEN transaction_account_id = 2 THEN balance - 1500
-        WHEN transaction_account_id = 3 THEN balance + 200
-        ELSE balance
-    END
-WHERE transaction_account_id IN (2, 3);
-}}}
- - Го известува корисникот за успешното додавање на трансакцијата.
-
-https://develop.finki.ukim.mk/projects/FEIN/wiki/PregledNaTransakcii
-== Преглед на сите трансакции
-
-==== Актери: **Најавен корисник**
-1. Корисникот пристапува до табот „Трансакции“ од главното мени на апликацијата
-2. Апликацијата испраќа SQL прашање до базата за да ги пронајде сите трансакции поврзани со корисникот, хронолошки подредени
-{{{#!sql
-SELECT 
-    t.transaction_id, 
-    t.transaction_name, 
-    t.amount, 
-    t.net_amount, 
-    t.date, 
-    STRING_AGG(DISTINCT tg.tag_name, ', ') AS tag_names, -- Aggregate multiple tag names
-    ta.account_name
-FROM 
-    public."transaction" t
-JOIN 
-    public.transaction_breakdown tb ON t.transaction_id = tb.transaction_id
-JOIN 
-    public.transaction_account ta ON tb.transaction_account_id = ta.transaction_account_id
-LEFT JOIN 
-    public.tag_assigned_to_transaction tat ON t.transaction_id = tat.transaction_id
-LEFT JOIN 
-    public.tag tg ON tat.tag_id = tg.tag_id
-WHERE 
-    ta.user_id = 101
-GROUP BY 
-    t.transaction_id, t.transaction_name, t.amount, t.net_amount, t.date, ta.account_name
-ORDER BY 
-    t.date DESC;
-}}}
-3. Апликацијата ги прикажува трансакциите како листа, со следниве информации за секоја трансакција:
- - ID на трансакција
- - Име на трансакција
- - Износ (amount)
- - Нето износ (net_amount)
- - Датум на трансакцијата
- - Таг/ови (ако постојат)
- - Сметка на која е поврзана трансакцијата
-4. До секоја трансакција, апликацијата прикажува две копчиња:
- - „Ажурирај“
- - „Избриши“
-
-http://develop.finki.ukim.mk/projects/FEIN/wiki/AzuriranjeBrisenjeTransakcija
-== Ажурирање/бришење на трансакции
-
-==== Актери: **Најавен корисник**
-
-=== Сценарио за ажурирање на трансакција
-1. Корисникот пристапува до табот „Трансакции“ од главното мени на апликацијата
-2. Корисникот притиска на копчето „Ажурирај“ од одредена трансакција
-3. Се отвора форма со веќе пополнети податоци за избраната трансакција
-4. Корисникот ги менува податоците (на пр. износ, датум, тагови, или сметка)
-5. Апликацијата ги валидира внесените податоци и ги ажурира соодветните записи во базата:
-{{{#!sql
-UPDATE transaction
-SET transaction_name = 'Променето име',
-    amount = 4000,
-    date = '2024-11-24 10:30:00+02:00',
-WHERE transaction_id = 1;
-}}}
-6. Ако има промена на тагови, претходно внесените записи се бришат и се додаваат новите тагови:
-{{{#!sql
-DELETE FROM public.tag_assigned_to_transaction
-WHERE transaction_id = 1;
-
-INSERT INTO public.tag_assigned_to_transaction (transaction_id, tag_id)
-VALUES 
-    (1, 2),   -- Пример за ново додаден таг
-    (1, 3);   -- Пример за друг нов таг
-}}}
-7. Ако е направена промена во {{{amount}}}:
- - Ажурирањето тригерира промена на записите во {{{transaction_breakdown}}} и {{{transaction_account}}} преку тригер
-8. По успешно ажурирање, корисникот добива порака:
- - „Трансакцијата е успешно ажурирана!“
-
-=== Сценарио за бришење на трансакција
-1. Корисникот пристапува до табот „Трансакции“ од главното мени на апликацијата
-2. Корисникот притиска на копчето „Избриши“ од одредена трансакција
-3. Апликацијата прикажува порака за потврда:
- - „Дали сте сигурни дека сакате да ја избришете оваа трансакција?“
-4. Корисникот потврдува
-5. Апликацијата ги брише сите записи поврзани со трансакцијата:
- - Бришење од {{{transaction_breakdown}}}:
-{{{#!sql
-DELETE FROM transaction_breakdown
-WHERE transaction_id = 1;
-}}}
- - Бришење од {{{tag_assigned_to_transaction}}}:
-{{{#!sql
-DELETE FROM public.tag_assigned_to_transaction
-WHERE transaction_id = 1;
-}}}
- - Бришење од {{{transaction}}}:
-{{{#!sql
-DELETE FROM transaction
-WHERE transaction_id = 1;
-}}}
-6. Тригер автоматски го ажурира балансот на соодветната сметка во {{{transaction_account}}}
-7. Корисникот добива порака:
- - „Трансакцијата е успешно избришана!“
-
-https://develop.finki.ukim.mk/projects/FEIN/wiki/Izvestai
-== Извештаи
-
-==== Актери: Најавен корисник
-1. Корисникот пристапува до табот "Извештаи" од главното мени на апликацијата
-2. Се прикажува интерактивна страница со опции за избор на типот на извештај:
- - Годишен извештај
- - Месечен извештај
- - Извештај по тагови
- - Извештај по сметки
- - Трендови на потрошувачка
-3. Корисникот избира тип на извештај и според тоа се пренасочува кон страница за тој извештај
-
-===== Годишен извештај
-- Корисникот избира "Годишен извештај" и потоа одбира година од dropdown мени
-- Апликацијата прикажува пита дијаграм со распределба на трошоците по тагови за избраната година
-- Дополнително, се прикажува график на времеплов (line chart) кој ги покажува вкупните месечни трошоци за секој месец од годината
-
-===== Месечен извештај
-- Корисникот избира "Месечен извештај" и потоа одбира месец и година од два dropdown менија
-- Апликацијата прикажува пита дијаграм со трошоците по тагови за избраниот месец
-- Дополнително, се прикажува график на времеплов (line chart) кој ги прикажува дневните трошоци во месецот
-
-===== Извештај по тагови
-- Корисникот избира "Извештај по тагови" и одбира еден или повеќе тагови од листа
-- Апликацијата прикажува пита дијаграм кој ги содржи сите избрани тагови
-- Под графикот се покажуваат вкупните трошоци за секој избран таг, подредени хронолошки
-
-===== Извештај по сметки
-- Корисникот избира "Извештај по сметки" и одбира сметка од листата
-- Апликацијата прикажува график на времеплов (line chart) сите приходи и расходи за избраната сметка
-- Апликацијата прикажува листа која ги покажува сите приходи и расходи за избраната сметка под графикот
-
-===== Трендови на потрошувачка
-- Корисникот избира "Трендови на потрошувачка" и времетраење (година, месец, или недела)
-- Апликацијата прикажува график кој ги споредува трендовите на трошоците за секој период (на пр. потрошувачка за јануари 2023 наспроти јануари 2024)
-
-https://develop.finki.ukim.mk/projects/FEIN/wiki/AdvancedReports
-= Напредни извештаи од базата (SQL, складирани процедури и релациона алгебра)
-
-- [wiki:AdvancedReport1 Листа на трансакции кои го надминале балансот на една сметка подредени хронолошки]
-- [wiki:AdvancedReport2 Надминување на баланс на една сметка со трансакција во сегашно време]
-- [wiki:AdvancedReport3 Листа на трансакции кои го надминале балансот на сите сметки вкупно подредени хронолошки]
-- [wiki:AdvancedReport4 Надминување на вкупниот баланс на сите сметки на корисникот во сегашно време]
-- [wiki:AdvancedReport5 Сумарни приходи и расходи по месеци]
-- [wiki:AdvancedReport6 Број на трансакции што ги надминуваат приходите на сметка]
-- [wiki:AdvancedReport7 Сумарни податоци за тагови]
-- [wiki:AdvancedReport8 Просечно трошење во последните 3 дена за цел систем]
-- [wiki:AdvancedReport9 Просечно трошење во последните 3 дена за корисник]
-- [wiki:AdvancedReport25 Просечно трошење во последните N дена]
-- [wiki:AdvancedReport10 Вкупно потрошено во тековниот месец за цел систем]
-- [wiki:AdvancedReport11 Вкупно потрошено во тековниот месец за корисник]
-- [wiki:AdvancedReport12 Дневен буџет до крајот на месецот за корисник]
-- [wiki:AdvancedReport13 Долг на кредитна картичка од минатиот месец за цел систем]
-- [wiki:AdvancedReport14 Долг на кредитна картичка од минатиот месец за корисник]
-- [wiki:AdvancedReport15 Трендови на трошење според тагови за цел систем]
-- [wiki:AdvancedReport16 Трендови на трошење според тагови за корисник]
-- [wiki:AdvancedReport17 Вкупно трошење според тагови]
-- [wiki:AdvancedReport18 Сметки со највисоко вкупно трошење во изминатата година]
-- [wiki:AdvancedReport19 Најчести времиња за трансакции]
-- [wiki:AdvancedReport20 Трансакции според тагови со највисоко трошење за цел систем]
-- [wiki:AdvancedReport21 Трансакции според тагови со највисоко трошење за корисник]
-- [wiki:AdvancedReport22 Годишни трендови на трансакции за цел систем]
-- [wiki:AdvancedReport23 Годишни трендови на трансакции за корисник]
-- [wiki:AdvancedReport24 Неактивни тагови]
-
-== 
-
-[wiki:AdvancedReportsVersion1 Верзија 1]
-https://develop.finki.ukim.mk/projects/FEIN/wiki/AdvancedReport1
-==== Листа на трансакции кои го надминале балансот на една сметка подредени хронолошки
-{{{#!sql
-CREATE OR REPLACE FUNCTION get_transactions_exceeding_account_balance(
-    p_account_name TEXT
-)
-RETURNS TABLE (
-    user_id INT,
-    user_name TEXT,
-    account_name TEXT,
-    transaction_id INT,
-    transaction_name TEXT,
-    transaction_amount NUMERIC,
-    transaction_date TIMESTAMP,
-    calculated_balance NUMERIC
-)
-LANGUAGE plpgsql
-AS $$
-BEGIN
-    RETURN QUERY
-    WITH cumulative_balances AS (
-        SELECT
-            u.user_id,
-            u.user_name,
-            ta.account_name,
-            t.transaction_id,
-            t.transaction_name,
-            tb.spent_amount AS transaction_amount,
-            t.date AS transaction_date,
-            SUM(tb.earned_amount - tb.spent_amount)
-                OVER (PARTITION BY ta.transaction_account_id ORDER BY t.date)
-                AS calculated_balance
-        FROM transaction_account ta
-        JOIN "user" u ON ta.user_id = u.user_id
-        JOIN transaction_breakdown tb ON ta.transaction_account_id = tb.transaction_account_id
-        JOIN transaction t ON tb.transaction_id = t.transaction_id
-        WHERE ta.account_name = p_account_name
-    )
-    SELECT *
-    FROM cumulative_balances
-    WHERE transaction_amount > calculated_balance
-      AND transaction_amount > 0
-    ORDER BY transaction_date DESC;
-END;
-$$;
-
-}}}
-
-==== Релациона алгебра
-- U(user_id, user_name)
-- TA(transaction_account_id, user_id, account_name)
-- TB(transaction_id, transaction_account_id, spent_amount, earned_amount)
-- T(transaction_id, transaction_name, date)
-
-JOIN на сите табели:
-- J1 ← TA ⨝,,TA.user_id = U.user_id,, U
-- J2 ← J1 ⨝,,TA.transaction_account_id = TB.transaction_account_id,, TB
-- J3 ← J2 ⨝,,TB.transaction_id = T.transaction_id,, T
-
-Филтер според име на акаунт:
-- F1 ← σ,,account_name = p_account_name,,(J3)
-
-Пресметка на кумулативен баланс:
-- CB ← γ,,user_id, user_name, account_name, transaction_id, transaction_name, transaction_amount, transaction_date; Σ(transaction_amount) OVER (PARTITION BY transaction_account_id ORDER BY transaction_date) → calculated_balance,,(F1)
-
-каде `transaction_amount = spent_amount` и кумулативната сума е `Σ(earned_amount - spent_amount)`
-
-Филтрирање на трансакциите кои го надминуваат балансот:
-- R ← σ,,transaction_amount > calculated_balance ∧ transaction_amount > 0,,(CB)
-
-Подредување хронолошки(почнувајќи од најновата трансакција до најстарата):
-- R_final ← τ,,transaction_date DESC,,(R)
-https://develop.finki.ukim.mk/projects/FEIN/wiki/AdvancedReport2
-==== Надминување на баланс на една сметка со трансакција во сегашно време
-{{{#!sql
-CREATE OR REPLACE FUNCTION get_current_account_overdrafts()
-RETURNS TABLE (
-    user_id INT,
-    user_name TEXT,
-    account_name TEXT,
-    current_balance NUMERIC,
-    transaction_id INT,
-    transaction_name TEXT,
-    transaction_amount NUMERIC,
-    transaction_date TIMESTAMP
-)
-LANGUAGE plpgsql
-AS $$
-BEGIN
-    RETURN QUERY
-    SELECT
-        u.user_id,
-        u.user_name,
-        ta.account_name,
-        ta.balance,
-        t.transaction_id,
-        t.transaction_name,
-        tb.spent_amount,
-        t.date
-    FROM transaction_account ta
-    JOIN "user" u ON ta.user_id = u.user_id
-    JOIN transaction_breakdown tb ON ta.transaction_account_id = tb.transaction_account_id
-    JOIN transaction t ON tb.transaction_id = t.transaction_id
-    WHERE tb.spent_amount > ta.balance
-      AND tb.spent_amount > 0
-    ORDER BY t.date DESC;
-END;
-$$;
-}}}
-
-==== Релациона алгебра
-- U(user_id, user_name)
-- TA(transaction_account_id, user_id, account_name, balance)
-- TB(transaction_id, transaction_account_id, spent_amount)
-- T(transaction_id, transaction_name, date)
-
-JOIN на сите табели:
-- J1 ← TA ⨝,,TA.user_id = U.user_id,, U
-- J2 ← J1 ⨝,,TA.transaction_account_id = TB.transaction_account_id,, TB
-- J3 ← J2 ⨝,,TB.transaction_id = T.transaction_id,, T
-
-Филтрирање на трансакции што го надминуваат тековниот баланс:
-- R ← σ,,spent_amount > balance ∧ spent_amount > 0,,(J3)
-
-Подредување хронолошки (од најнова кон најстара трансакција):
-- R_final ← τ,,date DESC,,(R)
-https://develop.finki.ukim.mk/projects/FEIN/wiki/AdvancedReport3
-==== Листа на трансакции кои го надминале балансот на сите сметки вкупно подредени хронолошки
-{{{#!sql
-CREATE OR REPLACE FUNCTION get_transactions_exceeding_user_total_balance()
-RETURNS TABLE (
-    user_id INT,
-    user_name TEXT,
-    transaction_id INT,
-    transaction_name TEXT,
-    transaction_amount NUMERIC,
-    transaction_date TIMESTAMP,
-    calculated_total_balance NUMERIC
-)
-LANGUAGE plpgsql
-AS $$
-BEGIN
-    RETURN QUERY
-    WITH cumulative_totals AS (
-        SELECT
-            u.user_id,
-            u.user_name,
-            t.transaction_id,
-            t.transaction_name,
-            tb.spent_amount AS transaction_amount,
-            t.date AS transaction_date,
-            SUM(tb.earned_amount - tb.spent_amount)
-                OVER (PARTITION BY u.user_id ORDER BY t.date)
-                AS calculated_total_balance
-        FROM transaction_account ta
-        JOIN "user" u ON ta.user_id = u.user_id
-        JOIN transaction_breakdown tb ON ta.transaction_account_id = tb.transaction_account_id
-        JOIN transaction t ON tb.transaction_id = t.transaction_id
-    )
-    SELECT *
-    FROM cumulative_totals
-    WHERE transaction_amount > calculated_total_balance
-      AND transaction_amount > 0
-    ORDER BY user_id, transaction_date DESC;
-END;
-$$;
-}}}
-
-==== Релациона алгебра
-- U(user_id, user_name)
-- TA(transaction_account_id, user_id)
-- TB(transaction_id, transaction_account_id, spent_amount, earned_amount)
-- T(transaction_id, transaction_name, date)
-
-JOIN на сите табели:
-- J1 ← TA ⨝,,TA.user_id = U.user_id,, U
-- J2 ← J1 ⨝,,TA.transaction_account_id = TB.transaction_account_id,, TB
-- J3 ← J2 ⨝,,TB.transaction_id = T.transaction_id,, T
-
-Пресметка на вкупен кумулативен баланс по корисник:
-- CT ← γ,,user_id, user_name, transaction_id, transaction_name, transaction_amount, transaction_date; Σ(transaction_amount) OVER (PARTITION BY user_id ORDER BY transaction_date) → calculated_total_balance,,(J3)
-
-каде `transaction_amount = spent_amount` и кумулативната сума е  
-`Σ(earned_amount - spent_amount)` за сите сметки на корисникот
-
-Филтрирање на трансакции кои го надминуваат вкупниот баланс:
-- R ← σ,,transaction_amount > calculated_total_balance ∧ transaction_amount > 0,,(CT)
-
-Подредување хронолошки (по корисник, од најнова кон најстара трансакција):
-- R_final ← τ,,user_id, transaction_date DESC,,(R)
-https://develop.finki.ukim.mk/projects/FEIN/wiki/AdvancedReport4
-==== Надминување на вкупниот баланс на сите сметки на корисникот во сегашно време
-Овде ќе го собереме моменталниот баланс на сите сметки на корисникот и ќе провериме дали сумата на моменталната трансакцијата ја надминува вкупна вредност
-{{{#!sql
-CREATE OR REPLACE FUNCTION get_current_user_total_balance_overdrafts()
-RETURNS TABLE (
-    user_id INT,
-    user_name TEXT,
-    transaction_id INT,
-    transaction_amount NUMERIC,
-    user_total_balance NUMERIC
-)
-LANGUAGE plpgsql
-AS $$
-BEGIN
-    RETURN QUERY
-    WITH total_user_balance AS (
-        SELECT
-            u.user_id,
-            SUM(ta.balance) AS total_balance
-        FROM transaction_account ta
-        JOIN "user" u ON ta.user_id = u.user_id
-        GROUP BY u.user_id
-    )
-    SELECT
-        u.user_id,
-        u.user_name,
-        t.transaction_id,
-        tb.spent_amount AS transaction_amount,
-        tub.total_balance AS user_total_balance
-    FROM transaction_account ta
-    JOIN "user" u ON ta.user_id = u.user_id
-    JOIN transaction_breakdown tb ON ta.transaction_account_id = tb.transaction_account_id
-    JOIN transaction t ON tb.transaction_id = t.transaction_id
-    JOIN total_user_balance tub ON u.user_id = tub.user_id
-    WHERE
-        tb.spent_amount > tub.total_balance
-        AND tb.spent_amount > 0
-        AND t.date <= CURRENT_DATE;
-END;
-$$;
-}}}
-
-==== Релациона алгебра
-- U(user_id, user_name)
-- TA(transaction_account_id, user_id, balance)
-- TB(transaction_id, transaction_account_id, spent_amount)
-- T(transaction_id, date)
-
-JOIN за пресметка на вкупен моментален баланс по корисник:
-- J1 ← TA ⨝,,TA.user_id = U.user_id,, U
-- TBAL ← γ,,user_id; Σ(balance) → total_balance,,(J1)
-
-JOIN на сите табели за трансакции:
-- J2 ← TA ⨝,,TA.user_id = U.user_id,, U
-- J3 ← J2 ⨝,,TA.transaction_account_id = TB.transaction_account_id,, TB
-- J4 ← J3 ⨝,,TB.transaction_id = T.transaction_id,, T
-- J5 ← J4 ⨝,,U.user_id = TBAL.user_id,, TBAL
-
-Филтрирање на трансакции кои го надминуваат ВКУПНИОТ моментален баланс:
-- R ← σ,,spent_amount > total_balance ∧ spent_amount > 0 ∧ date ≤ CURRENT_DATE,,(J5)
-
-Подредување по корисник:
-- R_final ← τ,,user_id,,(R)
-https://develop.finki.ukim.mk/projects/FEIN/wiki/AdvancedReport5
-==== Сумарни приходи и расходи по месеци
-Сумарните приходи и расходи (состојба) во целиот систем, поделени по месеци, со цел да се видат проблематичните месеци, се пресметува вкупниот приход и расход за секој месец, а разликата меѓу нив ќе ја даде состојбата
-{{{#!sql
-CREATE OR REPLACE FUNCTION get_monthly_system_balance()
-RETURNS TABLE (
-    month TEXT,
-    total_income NUMERIC,
-    total_expense NUMERIC,
-    net_balance NUMERIC
-)
-LANGUAGE plpgsql
-AS $$
-BEGIN
-    RETURN QUERY
-    SELECT
-        TO_CHAR(t.date, 'YYYY-MM'),
-        SUM(tb.earned_amount),
-        SUM(tb.spent_amount),
-        SUM(tb.earned_amount) - SUM(tb.spent_amount)
-    FROM transaction t
-    JOIN transaction_breakdown tb ON t.transaction_id = tb.transaction_id
-    GROUP BY TO_CHAR(t.date, 'YYYY-MM')
-    ORDER BY month;
-END;
-$$;
-}}}
-Проблематичен месец е месец каде што {{{net_balance}}} е негативен. Резултатите можат да се прикажат во графикон (на пример, бар график или линиски график) за подобра анализа. Може да се додаде процентуална промена меѓу месеците за да се откријат трендовите во приходите и трошоците
-
-==== Релациона алгебра
-- T(transaction_id, date)
-- TB(transaction_id, earned_amount, spent_amount)
-
-JOIN на табелите:
-- J1 ← T ⨝,,T.transaction_id = TB.transaction_id,, TB
-
-Групирање по месец и пресметка на приходи и расходи:
-- M ← γ,,month = FORMAT(date, 'YYYY-MM'); Σ(earned_amount) → total_income, Σ(spent_amount) → total_expense,,(J1)
-
-Пресметка на нето состојба:
-- R ← π,,month, total_income, total_expense, (total_income − total_expense) → net_balance,,(M)
-
-Подредување по месец:
-- R_final ← τ,,month,,(R)
-
-> За само проблематични месеци, се додава уште еден чекор:
-
-Филтрирање на проблематични месеци (негативна состојба):
-- P ← σ,,net_balance < 0,,(R_final)
-https://develop.finki.ukim.mk/projects/FEIN/wiki/AdvancedReport6
-==== Број на трансакции што ги надминуваат приходите на сметка
-{{{#!sql
-CREATE OR REPLACE FUNCTION get_account_transaction_overdraft_counts()
-RETURNS TABLE (
-    account_name TEXT,
-    transactions_exceeding_balance INT
-)
-LANGUAGE plpgsql
-AS $$
-BEGIN
-    RETURN QUERY
-    WITH cumulative_balances AS (
-        SELECT
-            ta.account_name,
-            t.transaction_id,
-            tb.spent_amount,
-            SUM(tb.earned_amount - tb.spent_amount)
-                OVER (PARTITION BY ta.transaction_account_id ORDER BY t.date)
-                AS calculated_balance
-        FROM transaction_account ta
-        JOIN transaction_breakdown tb
-            ON ta.transaction_account_id = tb.transaction_account_id
-        JOIN transaction t
-            ON tb.transaction_id = t.transaction_id
-    )
-    SELECT
-        account_name,
-        COUNT(transaction_id) AS transactions_exceeding_balance
-    FROM cumulative_balances
-    WHERE
-        spent_amount > calculated_balance
-        AND spent_amount > 0
-    GROUP BY account_name
-    ORDER BY transactions_exceeding_balance DESC;
-END;
-$$;
-}}}
-
-==== Релациона алгебра
-- TA(transaction_account_id, account_name)
-- TB(transaction_id, transaction_account_id, spent_amount, earned_amount)
-- T(transaction_id, date)
-
-JOIN на сите табели:
-- J1 ← TA ⨝,,TA.transaction_account_id = TB.transaction_account_id,, TB
-- J2 ← J1 ⨝,,TB.transaction_id = T.transaction_id,, T
-
-Пресметка на кумулативен баланс по сметка:
-- CB ← γ,,account_name, transaction_id, spent_amount; Σ(earned_amount - spent_amount) OVER (PARTITION BY transaction_account_id ORDER BY date) → calculated_balance,,(J2)
-
-Филтрирање на трансакции што го надминуваат балансот:
-- F ← σ,,spent_amount > calculated_balance ∧ spent_amount > 0,,(CB)
-
-Броење на трансакции по сметка:
-- R ← γ,,account_name; COUNT(transaction_id) → transactions_exceeding_balance,,(F)
-
-Подредување по број на прекршувања:
-- R_final ← τ,,transactions_exceeding_balance DESC,,(R)
-
-== 
-
-> Ако сакаме да ги добиеме вкупните трансакции кои ги надминуваат средствата низ целиот систем, без групирање по сметки:
-{{{#!sql
-CREATE OR REPLACE FUNCTION get_total_transaction_overdraft_count()
-RETURNS TABLE (
-    total_transactions_exceeding_balance INT
-)
-LANGUAGE plpgsql
-AS $$
-BEGIN
-    RETURN QUERY
-    WITH cumulative_total_balances AS (
-        SELECT
-            t.transaction_id,
-            tb.spent_amount,
-            SUM(tb.earned_amount - tb.spent_amount)
-                OVER (PARTITION BY u.user_id ORDER BY t.date)
-                AS calculated_total_balance
-        FROM transaction_account ta
-        JOIN "user" u
-            ON ta.user_id = u.user_id
-        JOIN transaction_breakdown tb
-            ON ta.transaction_account_id = tb.transaction_account_id
-        JOIN transaction t
-            ON tb.transaction_id = t.transaction_id
-    )
-    SELECT
-        COUNT(transaction_id) AS total_transactions_exceeding_balance
-    FROM cumulative_total_balances
-    WHERE
-        spent_amount > calculated_total_balance
-        AND spent_amount > 0;
-END;
-$$;
-}}}
-
-==== Релациона алгебра
-- U(user_id)
-- TA(transaction_account_id, user_id)
-- TB(transaction_id, transaction_account_id, spent_amount, earned_amount)
-- T(transaction_id, date)
-
-JOIN на сите табели:
-- J1 ← TA ⨝,,TA.user_id = U.user_id,, U
-- J2 ← J1 ⨝,,TA.transaction_account_id = TB.transaction_account_id,, TB
-- J3 ← J2 ⨝,,TB.transaction_id = T.transaction_id,, T
-
-Пресметка на кумулативен вкупен баланс по корисник:
-- CT ← γ,,transaction_id, spent_amount; Σ(earned_amount - spent_amount) OVER (PARTITION BY user_id ORDER BY date) → calculated_total_balance,,(J3)
-
-Филтрирање на трансакции што го надминуваат вкупниот баланс:
-- F ← σ,,spent_amount > calculated_total_balance ∧ spent_amount > 0,,(CT)
-
-Броење на сите прекршувачки трансакции:
-- R_final ← γ,,; COUNT(transaction_id) → total_transactions_exceeding_balance,,(F)
-https://develop.finki.ukim.mk/projects/FEIN/wiki/AdvancedReport7
-==== Сумарни податоци за тагови
-Сумирани податоци каде редовите се месеци, а колоните се тагови, а во полината се покажуваат различни сумирани податоци:
-===== Вкупен број на трансакции
-{{{#!sql
-CREATE OR REPLACE FUNCTION get_monthly_transaction_count_by_tag()
-RETURNS SETOF RECORD
-LANGUAGE plpgsql
-AS $$
-DECLARE
-    col_list TEXT;
-    dynamic_query TEXT;
-BEGIN
-    SELECT STRING_AGG(
-        DISTINCT format(
-            'SUM(CASE WHEN tg.tag_name = %L THEN 1 ELSE 0 END) AS %I',
-            tag_name, tag_name
-        ),
-        ', '
-    )
-    INTO col_list
-    FROM tag;
-
-    dynamic_query := format(
-        'SELECT
-            TO_CHAR(t.date, ''YYYY-MM'') AS transaction_month,
-            %s
-         FROM transaction t
-         JOIN tag_assigned_to_transaction tat ON t.transaction_id = tat.transaction_id
-         JOIN tag tg ON tat.tag_id = tg.tag_id
-         GROUP BY transaction_month
-         ORDER BY transaction_month;',
-        col_list
-    );
-
-    RETURN QUERY EXECUTE dynamic_query;
-END;
-$$;
-}}}
-
-===== Релациона алгебра
-- T(transaction_id, date)
-- TAT(transaction_id, tag_id)
-- TG(tag_id, tag_name)
-
-JOIN на сите табели:
-- J1 ← T ⨝,,T.transaction_id = TAT.transaction_id,, TAT
-- J2 ← J1 ⨝,,TAT.tag_id = TG.tag_id,, TG
-
-Групирање по месец и таг:
-- G ← γ,,month = FORMAT(date, 'YYYY-MM'), tag_name; COUNT(transaction_id) → transaction_count,,(J2)
-
-== 
-
-===== Вкупно примени средства
-{{{#!sql
-CREATE OR REPLACE FUNCTION get_monthly_income_by_tag()
-RETURNS SETOF RECORD
-LANGUAGE plpgsql
-AS $$
-DECLARE
-    col_list TEXT;
-    dynamic_query TEXT;
-BEGIN
-    SELECT STRING_AGG(
-        DISTINCT format(
-            'SUM(CASE WHEN tg.tag_name = %L THEN tb.earned_amount ELSE 0 END) AS %I',
-            tag_name, tag_name
-        ),
-        ', '
-    )
-    INTO col_list
-    FROM tag;
-
-    dynamic_query := format(
-        'SELECT
-            TO_CHAR(t.date, ''YYYY-MM'') AS transaction_month,
-            %s
-         FROM transaction t
-         JOIN transaction_breakdown tb ON t.transaction_id = tb.transaction_id
-         JOIN tag_assigned_to_transaction tat ON t.transaction_id = tat.transaction_id
-         JOIN tag tg ON tat.tag_id = tg.tag_id
-         GROUP BY transaction_month
-         ORDER BY transaction_month;',
-        col_list
-    );
-
-    RETURN QUERY EXECUTE dynamic_query;
-END;
-$$;
-}}}
-
-==== Релациона алгебра
-- T(transaction_id, date)
-- TB(transaction_id, earned_amount)
-- TAT(transaction_id, tag_id)
-- TG(tag_id, tag_name)
-
-JOIN на сите табели:
-- J1 ← T ⨝,,T.transaction_id = TB.transaction_id,, TB
-- J2 ← J1 ⨝,,T.transaction_id = TAT.transaction_id,, TAT
-- J3 ← J2 ⨝,,TAT.tag_id = TG.tag_id,, TG
-
-Групирање по месец и таг:
-- G ← γ,,month = FORMAT(date, 'YYYY-MM'), tag_name; Σ(earned_amount) → total_income,,(J3)
-
-== 
-
-===== Вкупно потрошени средства
-{{{#!sql
-CREATE OR REPLACE FUNCTION get_monthly_expense_by_tag()
-RETURNS SETOF RECORD
-LANGUAGE plpgsql
-AS $$
-DECLARE
-    col_list TEXT;
-    dynamic_query TEXT;
-BEGIN
-    SELECT STRING_AGG(
-        DISTINCT format(
-            'SUM(CASE WHEN tg.tag_name = %L THEN tb.spent_amount ELSE 0 END) AS %I',
-            tag_name, tag_name
-        ),
-        ', '
-    )
-    INTO col_list
-    FROM tag;
-
-    dynamic_query := format(
-        'SELECT
-            TO_CHAR(t.date, ''YYYY-MM'') AS transaction_month,
-            %s
-         FROM transaction t
-         JOIN transaction_breakdown tb ON t.transaction_id = tb.transaction_id
-         JOIN tag_assigned_to_transaction tat ON t.transaction_id = tat.transaction_id
-         JOIN tag tg ON tat.tag_id = tg.tag_id
-         GROUP BY transaction_month
-         ORDER BY transaction_month;',
-        col_list
-    );
-
-    RETURN QUERY EXECUTE dynamic_query;
-END;
-$$;
-}}}
-
-==== Релациона алгебра
-- T(transaction_id, date)
-- TB(transaction_id, spent_amount)
-- TAT(transaction_id, tag_id)
-- TG(tag_id, tag_name)
-
-JOIN на сите табели:
-- J1 ← T ⨝,,T.transaction_id = TB.transaction_id,, TB
-- J2 ← J1 ⨝,,T.transaction_id = TAT.transaction_id,, TAT
-- J3 ← J2 ⨝,,TAT.tag_id = TG.tag_id,, TG
-
-Групирање по месец и таг:
-- G ← γ,,month = FORMAT(date, 'YYYY-MM'), tag_name; Σ(spent_amount) → total_expense,,(J3)
-https://develop.finki.ukim.mk/projects/FEIN/wiki/AdvancedReport8
-==== Просечно трошење во последните 3 дена за цел систем
-Просечно дневно трошење за последните три дена, земајќи ги предвид сите трансакции:
-{{{#!sql
-CREATE OR REPLACE FUNCTION get_average_daily_spending_last_3_days_fixed()
-RETURNS TABLE (
-    average_spending_last_3_days NUMERIC
-)
-LANGUAGE plpgsql
-AS $$
-BEGIN
-    RETURN QUERY
-    SELECT
-        SUM(daily_spending) / 3 AS average_spending_last_3_days
-    FROM (
-        SELECT
-            d.day::date AS transaction_date,
-            COALESCE(SUM(tb.spent_amount), 0) AS daily_spending
-        FROM (
-            SELECT CURRENT_DATE - 2 AS day
-            UNION ALL
-            SELECT CURRENT_DATE - 1
-            UNION ALL
-            SELECT CURRENT_DATE
-        ) d
-        LEFT JOIN transaction t
-            ON t.date::date = d.day
-        LEFT JOIN transaction_breakdown tb
-            ON t.transaction_id = tb.transaction_id
-        GROUP BY d.day
-    ) daily_totals;
-END;
-$$;
-}}}
-
-==== Релациона алгебра
-- T(transaction_id, date)
-- TB(transaction_id, spent_amount)
-- D(day)   -- генерирана релација со последните 3 дена
-
-Генерирање на последните 3 дена:
-- D ← { CURRENT_DATE − 2, CURRENT_DATE − 1, CURRENT_DATE }
-
-LEFT JOIN со трансакции:
-- J1 ← D ⟕,,DATE(T.date) = D.day,, T
-- J2 ← J1 ⟕,,T.transaction_id = TB.transaction_id,, TB
-
-Групирање по ден и пресметка на дневно трошење:
-- G ← γ,,day; Σ(spent_amount) → daily_spending,,(J2)
-
-Пресметка на просек (фиксно делење со 3):
-- R_final ← γ,,; (Σ(daily_spending) / 3) → average_spending_last_3_days,,(G)
-https://develop.finki.ukim.mk/projects/FEIN/wiki/AdvancedReport9
-==== Просечно трошење во последните 3 дена за корисник
-Просечно дневно трошење за последните 3 дена за одреден корисник:
-{{{#!sql
-CREATE OR REPLACE FUNCTION get_average_daily_spending_last_3_days_user(
-    p_user_id INT
-)
-RETURNS TABLE (
-    average_spending_last_3_days NUMERIC
-)
-LANGUAGE plpgsql
-AS $$
-BEGIN
-    RETURN QUERY
-    SELECT
-        SUM(daily_spending) / 3 AS average_spending_last_3_days
-    FROM (
-        SELECT
-            d.day::date AS transaction_date,
-            COALESCE(SUM(tb.spent_amount), 0) AS daily_spending
-        FROM (
-            SELECT CURRENT_DATE - 2 AS day
-            UNION ALL
-            SELECT CURRENT_DATE - 1
-            UNION ALL
-            SELECT CURRENT_DATE
-        ) d
-        LEFT JOIN transaction_account ta
-            ON ta.user_id = p_user_id
-        LEFT JOIN transaction_breakdown tb
-            ON ta.transaction_account_id = tb.transaction_account_id
-        LEFT JOIN transaction t
-            ON t.transaction_id = tb.transaction_id
-            AND t.date::date = d.day
-        GROUP BY d.day
-    ) daily_totals;
-END;
-$$;
-}}}
-
-==== Релациона алгебра
-- TA(transaction_account_id, user_id)
-- T(transaction_id, date)
-- TB(transaction_id, spent_amount)
-- D(day) -- генерирани последните 3 дена
-
-Генерирање на последните 3 дена:
-- D ← { CURRENT_DATE − 2, CURRENT_DATE − 1, CURRENT_DATE }
-
-LEFT JOIN со сметка на корисник:
-- J1 ← D ⟕,,TA.user_id = p_user_id,, TA
-- J2 ← J1 ⟕,,TA.transaction_account_id = TB.transaction_account_id,, TB
-- J3 ← J2 ⟕,,TB.transaction_id = T.transaction_id ∧ DATE(T.date) = D.day,, T
-
-Групирање по ден и пресметка на дневно трошење:
-- G ← γ,,day; Σ(spent_amount) → daily_spending,,(J3)
-
-Просечно дневно трошење (фиксно делење со 3):
-- R_final ← γ,,; (Σ(daily_spending) / 3) → average_spending_last_3_days,,(G)
-https://develop.finki.ukim.mk/projects/FEIN/wiki/AdvancedReport25
-==== Просечно трошење во последните N дена
-- по корисник (со параметар p_user_id)
-- за цел систем (со параметар p_user_id = NULL)
-
-{{{#!sql
-CREATE OR REPLACE FUNCTION get_average_daily_spending_last_N_days(
-    p_days INT,
-    p_user_id INT DEFAULT NULL  -- ако е NULL, се зема цел систем
-)
-RETURNS TABLE (
-    average_spending NUMERIC
-)
-LANGUAGE plpgsql
-AS $$
-BEGIN
-    RETURN QUERY
-    SELECT
-        SUM(daily_spending) / p_days AS average_spending
-    FROM (
-        -- генерација на последни p_days
-        SELECT
-            d.day::date AS transaction_date,
-            COALESCE(SUM(tb.spent_amount), 0) AS daily_spending
-        FROM (
-            SELECT generate_series(CURRENT_DATE - (p_days - 1), CURRENT_DATE, '1 day') AS day
-        ) d
-        LEFT JOIN transaction t
-            ON t.date::date = d.day
-        LEFT JOIN transaction_breakdown tb
-            ON t.transaction_id = tb.transaction_id
-        LEFT JOIN transaction_account ta
-            ON tb.transaction_account_id = ta.transaction_account_id
-            AND (p_user_id IS NULL OR ta.user_id = p_user_id)
-        GROUP BY d.day
-    ) daily_totals;
-END;
-$$;
-}}}
-
-==== Релациона алгебра
-- TA(transaction_account_id, user_id)
-- T(transaction_id, date)
-- TB(transaction_id, spent_amount)
-- D(day) -- генерација на последните p_days
-
-Генерација на последни p_days:
-- D ← { CURRENT_DATE − (p_days − 1), ..., CURRENT_DATE }
-
-LEFT JOIN со трансакции:
-- J1 ← D ⟕,,TRUE,, T
-- J2 ← J1 ⟕,,T.transaction_id = TB.transaction_id,, TB
-- J3 ← J2 ⟕,,TB.transaction_account_id = TA.transaction_account_id ∧ (p_user_id IS NULL OR TA.user_id = p_user_id),, TA
-
-Групирање по ден и пресметка на дневно трошење:
-- G ← γ,,day; Σ(spent_amount) → daily_spending,,(J3)
-
-Просечно дневно трошење (фиксно делење со p_days):
-- R_final ← γ,,; (Σ(daily_spending) / p_days) → average_spending,,(G)
-https://develop.finki.ukim.mk/projects/FEIN/wiki/AdvancedReport10
-==== Вкупно потрошено во тековниот месец за цел систем
-Вкупна сума на трошоци во тековниот месец
-{{{#!sql
-CREATE OR REPLACE FUNCTION get_total_spent_current_month()
-RETURNS TABLE (
-    total_spent NUMERIC
-)
-LANGUAGE plpgsql
-AS $$
-BEGIN
-    RETURN QUERY
-    SELECT 
-        COALESCE(SUM(tb.spent_amount), 0) AS total_spent
-    FROM transaction_breakdown tb
-    JOIN transaction t
-        ON tb.transaction_id = t.transaction_id
-    WHERE EXTRACT(MONTH FROM t.date) = EXTRACT(MONTH FROM CURRENT_DATE)
-      AND EXTRACT(YEAR FROM t.date) = EXTRACT(YEAR FROM CURRENT_DATE);
-END;
-$$;
-}}}
-
-==== Релациона алгебра
-- TB(transaction_id, spent_amount)
-- T(transaction_id, date)
-
-JOIN на табелите:
-- J1 ← TB ⨝,,TB.transaction_id = T.transaction_id,, T
-
-Филтрирање на трансакции од тековниот месец и година:
-- F ← σ,,EXTRACT(MONTH FROM date) = EXTRACT(MONTH FROM CURRENT_DATE) ∧ EXTRACT(YEAR FROM date) = EXTRACT(YEAR FROM CURRENT_DATE),,(J1)
-
-Агрегација на вкупно потрошено:
-- R_final ← γ,,; SUM(spent_amount) → total_spent,,(F)
-https://develop.finki.ukim.mk/projects/FEIN/wiki/AdvancedReport11
-==== Вкупно потрошено во тековниот месец за корисник
-Вкупна сума на трошоци во тековниот месец на еден корисник
-{{{#!sql
-CREATE OR REPLACE FUNCTION get_total_spent_current_month_user(
-    p_user_id INT
-)
-RETURNS TABLE (
-    total_spent NUMERIC
-)
-LANGUAGE plpgsql
-AS $$
-BEGIN
-    RETURN QUERY
-    SELECT 
-        COALESCE(SUM(tb.spent_amount), 0) AS total_spent
-    FROM transaction_breakdown tb
-    JOIN transaction t
-        ON tb.transaction_id = t.transaction_id
-    JOIN transaction_account ta
-        ON tb.transaction_account_id = ta.transaction_account_id
-    WHERE ta.user_id = p_user_id
-      AND EXTRACT(MONTH FROM t.date) = EXTRACT(MONTH FROM CURRENT_DATE)
-      AND EXTRACT(YEAR FROM t.date) = EXTRACT(YEAR FROM CURRENT_DATE);
-END;
-$$;
-}}}
-
-==== Релациона алгебра
-- TA(transaction_account_id, user_id)
-- TB(transaction_id, transaction_account_id, spent_amount)
-- T(transaction_id, date)
-
-JOIN на сите табели:
-- J1 ← TA ⨝,,TA.transaction_account_id = TB.transaction_account_id,, TB
-- J2 ← J1 ⨝,,TB.transaction_id = T.transaction_id,, T
-
-Филтрирање на трансакции за даден корисник и тековен месец:
-- F ← σ,,user_id = p_user_id ∧ EXTRACT(MONTH FROM date) = EXTRACT(MONTH FROM CURRENT_DATE) ∧ EXTRACT(YEAR FROM date) = EXTRACT(YEAR FROM CURRENT_DATE),, (J2)
-
-Агрегација на вкупно потрошено:
-- R_final ← γ,,; SUM(spent_amount) → total_spent,,(F)
-https://develop.finki.ukim.mk/projects/FEIN/wiki/AdvancedReport12
-==== Дневен буџет до крајот на месецот за корисник
-Дневен буџет базиран на вкупниот баланс на сите сметки на корисникот
-{{{#!sql
-CREATE OR REPLACE FUNCTION get_daily_budget_user(
-    p_user_id INT
-)
-RETURNS TABLE (
-    daily_budget NUMERIC
-)
-LANGUAGE plpgsql
-AS $$
-BEGIN
-    RETURN QUERY
-    SELECT 
-        COALESCE(SUM(ta.balance), 0) / 
-        (DATE_PART('day', DATE_TRUNC('month', CURRENT_DATE + INTERVAL '1 MONTH') - INTERVAL '1 DAY') 
-         - DATE_PART('day', CURRENT_DATE) + 1) AS daily_budget
-    FROM transaction_account ta
-    WHERE ta.user_id = p_user_id;
-END;
-$$;
-}}}
-
-==== Релациона алгебра
-- TA(transaction_account_id, user_id, balance)
-
-Филтрирање по корисник:
-- F ← σ,,user_id = p_user_id,,(TA)
-
-Агрегација на вкупен баланс:
-- S ← γ,,; SUM(balance) → total_balance,,(F)
-
-Пресметка на дневен буџет до крајот на месецот:
-- R_final ← γ,,; total_balance / (DATE_PART('day', DATE_TRUNC('month', CURRENT_DATE + INTERVAL '1 MONTH') - INTERVAL '1 DAY')- DATE_PART('day', CURRENT_DATE) + 1) → daily_budget,,(S)
-https://develop.finki.ukim.mk/projects/FEIN/wiki/AdvancedReport13
-==== Долг на кредитна картичка од минатиот месец за цел систем
-Вкупна сума потрошена од сметките обележани како „кредитна картичка“ за минатиот месец
-{{{#!sql
-CREATE OR REPLACE FUNCTION get_credit_card_debt_last_month()
-RETURNS TABLE (
-    credit_card_debt NUMERIC
-)
-LANGUAGE plpgsql
-AS $$
-BEGIN
-    RETURN QUERY
-    SELECT 
-        COALESCE(SUM(tb.spent_amount), 0) AS credit_card_debt
-    FROM transaction_breakdown tb
-    JOIN transaction_account ta
-        ON tb.transaction_account_id = ta.transaction_account_id
-    JOIN transaction t
-        ON tb.transaction_id = t.transaction_id
-    WHERE (ta.account_name ILIKE '%кредитна%' OR ta.account_name ILIKE '%credit%')
-      AND EXTRACT(MONTH FROM t.date) = EXTRACT(MONTH FROM CURRENT_DATE - INTERVAL '1 MONTH')
-      AND EXTRACT(YEAR FROM t.date) = EXTRACT(YEAR FROM CURRENT_DATE - INTERVAL '1 MONTH');
-END;
-}}}
-
-==== Релациона алгебра
-- TA(transaction_account_id, account_name)
-- TB(transaction_id, transaction_account_id, spent_amount)
-- T(transaction_id, date)
-
-JOIN на сите табели:
-- J1 ← TA ⨝,,TA.transaction_account_id = TB.transaction_account_id,, TB
-- J2 ← J1 ⨝,,TB.transaction_id = T.transaction_id,, T
-
-Филтрирање на кредитни картички и минат месец:
-- F ← σ,,(account_name ILIKE '%кредитна%' OR account_name ILIKE '%credit%') ∧ EXTRACT(MONTH FROM date) = EXTRACT(MONTH FROM CURRENT_DATE - INTERVAL '1 MONTH') ∧ EXTRACT(YEAR FROM date) = EXTRACT(YEAR FROM CURRENT_DATE - INTERVAL '1 MONTH'),,(J2)
-
-Агрегација на вкупен долг:
-- R_final ← γ,,; SUM(spent_amount) → credit_card_debt,,(F)
-https://develop.finki.ukim.mk/projects/FEIN/wiki/AdvancedReport14
-==== Долг на кредитна картичка од минатиот месец за корисник
-Потрошено од сметки означени како „кредитна картичка“ за минатиот месец за еден корисник
-{{{#!sql
-CREATE OR REPLACE FUNCTION get_credit_card_debt_last_month_user(
-    p_user_id INT
-)
-RETURNS TABLE (
-    credit_card_debt NUMERIC
-)
-LANGUAGE plpgsql
-AS $$
-BEGIN
-    RETURN QUERY
-    SELECT 
-        COALESCE(SUM(tb.spent_amount), 0) AS credit_card_debt
-    FROM transaction_breakdown tb
-    JOIN transaction_account ta
-        ON tb.transaction_account_id = ta.transaction_account_id
-    JOIN transaction t
-        ON tb.transaction_id = t.transaction_id
-    WHERE ta.user_id = p_user_id
-      AND (ta.account_name ILIKE '%кредитна%' OR ta.account_name ILIKE '%credit%')
-      AND EXTRACT(MONTH FROM t.date) = EXTRACT(MONTH FROM CURRENT_DATE - INTERVAL '1 MONTH')
-      AND EXTRACT(YEAR FROM t.date) = EXTRACT(YEAR FROM CURRENT_DATE - INTERVAL '1 MONTH');
-END;
-$$;
-}}}
-
-==== Релациона алгебра
-- TA(transaction_account_id, user_id, account_name)
-- TB(transaction_id, transaction_account_id, spent_amount)
-- T(transaction_id, date)
-
-JOIN на сите табели:
-- J1 ← TA ⨝,,TA.transaction_account_id = TB.transaction_account_id,, TB
-- J2 ← J1 ⨝,,TB.transaction_id = T.transaction_id,, T
-
-Филтрирање по корисник, кредитна картичка и минат месец:
-- F ← σ,,user_id = p_user_id ∧ (account_name ILIKE '%кредитна%' OR account_name ILIKE '%credit%') ∧ EXTRACT(MONTH FROM date) = EXTRACT(MONTH FROM CURRENT_DATE - INTERVAL '1 MONTH') ∧ EXTRACT(YEAR FROM date) = EXTRACT(YEAR FROM CURRENT_DATE - INTERVAL '1 MONTH'),,(J2)
-
-Агрегација на вкупен долг:
-- R_final ← γ,,; SUM(spent_amount) → credit_card_debt,,(F)
-https://develop.finki.ukim.mk/projects/FEIN/wiki/AdvancedReport15
-==== Трендови на трошење според тагови за цел систем
-Трендови на трошење за секој таг во последните шест месеци
-{{{#!sql
-CREATE OR REPLACE FUNCTION get_spending_trends_last_6_months()
-RETURNS TABLE (
-    tag_name TEXT,
-    month DATE,
-    total_spent NUMERIC
-)
-LANGUAGE plpgsql
-AS $$
-BEGIN
-    RETURN QUERY
-    SELECT 
-        tg.tag_name, 
-        DATE_TRUNC('month', t.date) AS month,
-        COALESCE(SUM(tb.spent_amount), 0) AS total_spent
-    FROM tag tg
-    JOIN tag_assigned_to_transaction tat 
-        ON tg.tag_id = tat.tag_id
-    JOIN transaction t 
-        ON tat.transaction_id = t.transaction_id
-    JOIN transaction_breakdown tb 
-        ON t.transaction_id = tb.transaction_id
-    WHERE t.date >= DATE_TRUNC('month', CURRENT_DATE - INTERVAL '6 MONTHS')
-    GROUP BY tg.tag_name, month
-    ORDER BY tg.tag_name, month;
-END;
-$$;
-}}}
-
-==== Релациона алгебра
-- TG(tag_id, tag_name)
-- TAT(tag_id, transaction_id)
-- T(transaction_id, date)
-- TB(transaction_id, spent_amount)
-
-JOIN на сите табели:
-- J1 ← TG ⨝,,TG.tag_id = TAT.tag_id,, TAT
-- J2 ← J1 ⨝,,TAT.transaction_id = T.transaction_id,, T
-- J3 ← J2 ⨝,,T.transaction_id = TB.transaction_id,, TB
-
-Филтрирање на трансакции од последните 6 месеци:
-- F ← σ,,date >= DATE_TRUNC('month', CURRENT_DATE - INTERVAL '6 MONTHS'),,(J3)
-
-Групирање по таг и месец:
-- G ← γ,,tag_name, month = DATE_TRUNC('month', date); Σ(spent_amount) → total_spent,,(F)
-
-Подредување по таг и месец:
-- R_final ← τ,,tag_name, month,,(G)
-https://develop.finki.ukim.mk/projects/FEIN/wiki/AdvancedReport16
-==== Трендови на трошење според тагови за корисник
-Сумирање на трошењата според тагови во последните 6 месеци за одреден корисник
-{{{#!sql
-CREATE OR REPLACE FUNCTION get_spending_trends_last_6_months_user(
-    p_user_id INT
-)
-RETURNS TABLE (
-    tag_name TEXT,
-    month DATE,
-    total_spent NUMERIC
-)
-LANGUAGE plpgsql
-AS $$
-BEGIN
-    RETURN QUERY
-    SELECT 
-        tg.tag_name, 
-        DATE_TRUNC('month', t.date) AS month,
-        COALESCE(SUM(tb.spent_amount), 0) AS total_spent
-    FROM tag_assigned_to_transaction tat
-    JOIN transaction t 
-        ON tat.transaction_id = t.transaction_id
-    JOIN transaction_breakdown tb 
-        ON t.transaction_id = tb.transaction_id
-    JOIN transaction_account ta 
-        ON tb.transaction_account_id = ta.transaction_account_id
-    JOIN tag tg 
-        ON tat.tag_id = tg.tag_id
-    WHERE ta.user_id = p_user_id
-      AND t.date >= DATE_TRUNC('month', CURRENT_DATE - INTERVAL '6 MONTHS')
-    GROUP BY tg.tag_name, month
-    ORDER BY tg.tag_name, month;
-END;
-$$;
-}}}
-
-==== Релациона алгебра
-- TA(transaction_account_id, user_id)
-- T(transaction_id, date)
-- TB(transaction_id, transaction_account_id, spent_amount)
-- TAT(tag_id, transaction_id)
-- TG(tag_id, tag_name)
-
-JOIN на сите табели:
-- J1 ← TA ⨝,,TA.transaction_account_id = TB.transaction_account_id,, TB
-- J2 ← J1 ⨝,,TB.transaction_id = T.transaction_id,, T
-- J3 ← J2 ⨝,,T.transaction_id = TAT.transaction_id,, TAT
-- J4 ← J3 ⨝,,TAT.tag_id = TG.tag_id,, TG
-
-Филтрирање по корисник и последни 6 месеци:
-- F ← σ,,user_id = p_user_id ∧ date >= DATE_TRUNC('month', CURRENT_DATE - INTERVAL '6 MONTHS'),,(J4)
-
-Групирање по таг и месец:
-- G ← γ,,tag_name, month = DATE_TRUNC('month', date); Σ(spent_amount) → total_spent,,(F)
-
-Подредување по таг и месец:
-- R_final ← τ,,tag_name, month,,(G)
-https://develop.finki.ukim.mk/projects/FEIN/wiki/AdvancedReport17
-==== Вкупно трошење според тагови
-Вкупно трошење групирано според тагови за тековниот месец
-{{{#!sql
-CREATE OR REPLACE FUNCTION get_total_spent_by_tag_current_month()
-RETURNS TABLE (
-    tag_name TEXT,
-    total_spent NUMERIC
-)
-LANGUAGE plpgsql
-AS $$
-BEGIN
-    RETURN QUERY
-    SELECT 
-        tg.tag_name,
-        COALESCE(SUM(tb.spent_amount), 0) AS total_spent
-    FROM tag tg
-    JOIN tag_assigned_to_transaction tat 
-        ON tg.tag_id = tat.tag_id
-    JOIN transaction t 
-        ON tat.transaction_id = t.transaction_id
-    JOIN transaction_breakdown tb 
-        ON t.transaction_id = tb.transaction_id
-    WHERE EXTRACT(MONTH FROM t.date) = EXTRACT(MONTH FROM CURRENT_DATE)
-      AND EXTRACT(YEAR FROM t.date) = EXTRACT(YEAR FROM CURRENT_DATE)
-    GROUP BY tg.tag_name
-    ORDER BY total_spent DESC;
-END;
-$$;
-}}}
-
-==== Релациона алгебра
-- TG(tag_id, tag_name)
-- TAT(tag_id, transaction_id)
-- T(transaction_id, date)
-- TB(transaction_id, spent_amount)
-
-JOIN на сите табели:
-- J1 ← TG ⨝,,TG.tag_id = TAT.tag_id,, TAT
-- J2 ← J1 ⨝,,TAT.transaction_id = T.transaction_id,, T
-- J3 ← J2 ⨝,,T.transaction_id = TB.transaction_id,, TB
-
-Филтрирање по тековен месец и година:
-- F ← σ,,EXTRACT(MONTH FROM date) = EXTRACT(MONTH FROM CURRENT_DATE) ∧ EXTRACT(YEAR FROM date) = EXTRACT(YEAR FROM CURRENT_DATE),,(J3)
-
-Групирање и агрегација по таг:
-- G ← γ,,tag_name; SUM(spent_amount) → total_spent,,(F)
-
-Подредување по вкупно потрошено:
-- R_final ← τ,,total_spent DESC,,(G)
-https://develop.finki.ukim.mk/projects/FEIN/wiki/AdvancedReport18
-==== Сметки со највисоко вкупно трошење во изминатата година
-{{{#!sql
-CREATE OR REPLACE FUNCTION get_top_accounts_last_year()
-RETURNS TABLE (
-    account_name TEXT,
-    total_spent NUMERIC
-)
-LANGUAGE plpgsql
-AS $$
-BEGIN
-    RETURN QUERY
-    SELECT 
-        ta.account_name, 
-        COALESCE(SUM(tb.spent_amount), 0) AS total_spent
-    FROM transaction_breakdown tb
-    JOIN transaction t 
-        ON tb.transaction_id = t.transaction_id
-    JOIN transaction_account ta 
-        ON tb.transaction_account_id = ta.transaction_account_id
-    WHERE t.date >= NOW() - INTERVAL '1 YEAR'
-    GROUP BY ta.account_name
-    ORDER BY total_spent DESC
-    LIMIT 10;
-END;
-$$;
-}}}
-
-==== Релациона алгебра
-- TA(transaction_account_id, account_name)
-- TB(transaction_id, transaction_account_id, spent_amount)
-- T(transaction_id, date)
-
-JOIN на табелите:
-- J1 ← TA ⨝,,TA.transaction_account_id = TB.transaction_account_id,, TB
-- J2 ← J1 ⨝,,TB.transaction_id = T.transaction_id,, T
-
-Филтрирање по последна година:
-- F ← σ,,date >= NOW() - INTERVAL '1 YEAR',, (J2)
-
-Групирање и агрегација по сметка:
-- G ← γ,,account_name; SUM(spent_amount) → total_spent,,(F)
-
-Подредување по вкупно потрошено и лимитирање:
-- R_final ← τ,,total_spent DESC LIMIT 10,,(G)
-https://develop.finki.ukim.mk/projects/FEIN/wiki/AdvancedReport19
-==== Најчести времиња за трансакции
-Во кој час од денот корисниците најчесто вршат трансакции
-{{{#!sql
-CREATE OR REPLACE FUNCTION get_most_frequent_transaction_hours()
-RETURNS TABLE (
-    transaction_hour INT,
-    transaction_count BIGINT
-)
-LANGUAGE plpgsql
-AS $$
-BEGIN
-    RETURN QUERY
-    SELECT 
-        EXTRACT(HOUR FROM t.date)::INT AS transaction_hour, 
-        COUNT(*) AS transaction_count
-    FROM transaction t
-    GROUP BY transaction_hour
-    ORDER BY transaction_count DESC;
-END;
-$$;
-}}}
-
-==== Релациона алгебра
-- T(transaction_id, date)
-
-Групирање по час на трансакција:
-- G ← γ,,transaction_hour = EXTRACT(HOUR FROM date); COUNT(transaction_id) → transaction_count,,(T)
-
-Подредување по број на трансакции:
-- R_final ← τ,,transaction_count DESC,,(G)
-https://develop.finki.ukim.mk/projects/FEIN/wiki/AdvancedReport20
-==== Трансакции според тагови со највисоко трошење за цел систем
-{{{#!sql
-CREATE OR REPLACE FUNCTION get_total_spent_by_tag_system()
-RETURNS TABLE (
-    tag_name TEXT,
-    total_spent NUMERIC
-)
-LANGUAGE plpgsql
-AS $$
-BEGIN
-    RETURN QUERY
-    SELECT 
-        tg.tag_name, 
-        COALESCE(SUM(tb.spent_amount), 0) AS total_spent
-    FROM transaction_breakdown tb
-    JOIN transaction t 
-        ON tb.transaction_id = t.transaction_id
-    JOIN tag_assigned_to_transaction tat 
-        ON t.transaction_id = tat.transaction_id
-    JOIN tag tg 
-        ON tat.tag_id = tg.tag_id
-    GROUP BY tg.tag_name
-    ORDER BY total_spent DESC;
-END;
-$$;
-}}}
-
-==== Релациона алгебра
-- TB(transaction_id, spent_amount)
-- T(transaction_id, date)
-- TAT(tag_id, transaction_id)
-- TG(tag_id, tag_name)
-
-JOIN на сите табели:
-- J1 ← TB ⨝,,TB.transaction_id = T.transaction_id,, T
-- J2 ← J1 ⨝,,T.transaction_id = TAT.transaction_id,, TAT
-- J3 ← J2 ⨝,,TAT.tag_id = TG.tag_id,, TG
-
-Групирање и агрегација по таг:
-- G ← γ,,tag_name; SUM(spent_amount) → total_spent,,(J3)
-
-Подредување по вкупно потрошено:
-- R_final ← τ,,total_spent DESC,,(G)
-https://develop.finki.ukim.mk/projects/FEIN/wiki/AdvancedReport21
-==== Трансакции според тагови со највисоко трошење за корисник
-Сумирање на трошењата според тагови за одреден корисник
-{{{#!sql
-CREATE OR REPLACE FUNCTION get_total_spent_by_tag_user(
-    p_user_id INT
-)
-RETURNS TABLE (
-    tag_name TEXT,
-    total_spent NUMERIC
-)
-LANGUAGE plpgsql
-AS $$
-BEGIN
-    RETURN QUERY
-    SELECT 
-        tg.tag_name, 
-        COALESCE(SUM(tb.spent_amount), 0) AS total_spent
-    FROM transaction_breakdown tb
-    JOIN transaction t 
-        ON tb.transaction_id = t.transaction_id
-    JOIN transaction_account ta 
-        ON tb.transaction_account_id = ta.transaction_account_id
-    JOIN tag_assigned_to_transaction tat 
-        ON t.transaction_id = tat.transaction_id
-    JOIN tag tg 
-        ON tat.tag_id = tg.tag_id
-    WHERE ta.user_id = p_user_id
-    GROUP BY tg.tag_name
-    ORDER BY total_spent DESC;
-END;
-$$;
-}}}
-
-==== Релациона алгебра
-- TA(transaction_account_id, user_id)
-- TB(transaction_id, transaction_account_id, spent_amount)
-- T(transaction_id, date)
-- TAT(tag_id, transaction_id)
-- TG(tag_id, tag_name)
-
-JOIN на сите табели:
-- J1 ← TA ⨝,,TA.transaction_account_id = TB.transaction_account_id,, TB
-- J2 ← J1 ⨝,,TB.transaction_id = T.transaction_id,, T
-- J3 ← J2 ⨝,,T.transaction_id = TAT.transaction_id,, TAT
-- J4 ← J3 ⨝,,TAT.tag_id = TG.tag_id,, TG
-
-Филтрирање по корисник:
-- F ← σ,,user_id = p_user_id,,(J4)
-
-Групирање и агрегација по таг:
-- G ← γ,,tag_name; SUM(spent_amount) → total_spent,,(F)
-
-Подредување по вкупно потрошено:
-- R_final ← τ,,total_spent DESC,,(G)
-https://develop.finki.ukim.mk/projects/FEIN/wiki/AdvancedReport22
-==== Годишни трендови на трансакции за цел систем
-Трошења според сметки за секој квартал од тековната година
-{{{#!sql
-CREATE OR REPLACE FUNCTION get_quarterly_spending_trends_system()
-RETURNS TABLE (
-    account_name TEXT,
-    q1_spent NUMERIC,
-    q2_spent NUMERIC,
-    q3_spent NUMERIC,
-    q4_spent NUMERIC
-)
-LANGUAGE plpgsql
-AS $$
-BEGIN
-    RETURN QUERY
-    SELECT 
-        ta.account_name, 
-        SUM(CASE WHEN EXTRACT(QUARTER FROM t.date) = 1 THEN tb.spent_amount ELSE 0 END) AS q1_spent,
-        SUM(CASE WHEN EXTRACT(QUARTER FROM t.date) = 2 THEN tb.spent_amount ELSE 0 END) AS q2_spent,
-        SUM(CASE WHEN EXTRACT(QUARTER FROM t.date) = 3 THEN tb.spent_amount ELSE 0 END) AS q3_spent,
-        SUM(CASE WHEN EXTRACT(QUARTER FROM t.date) = 4 THEN tb.spent_amount ELSE 0 END) AS q4_spent
-    FROM transaction_breakdown tb
-    JOIN transaction t 
-        ON tb.transaction_id = t.transaction_id
-    JOIN transaction_account ta 
-        ON tb.transaction_account_id = ta.transaction_account_id
-    WHERE EXTRACT(YEAR FROM t.date) = EXTRACT(YEAR FROM CURRENT_DATE)
-    GROUP BY ta.account_name
-    ORDER BY ta.account_name;
-END;
-$$;
-}}}
-
-==== Релациона алгебра
-- TA(transaction_account_id, account_name)
-- TB(transaction_id, transaction_account_id, spent_amount)
-- T(transaction_id, date)
-
-JOIN на табелите:
-- J1 ← TA ⨝,,TA.transaction_account_id = TB.transaction_account_id,, TB
-- J2 ← J1 ⨝,,TB.transaction_id = T.transaction_id,, T
-
-Филтрирање по тековна година:
-- F ← σ,,EXTRACT(YEAR FROM date) = EXTRACT(YEAR FROM CURRENT_DATE),,(J2)
-
-Агрегација по квартали:
-- G ← γ,,account_name; SUM(CASE WHEN EXTRACT(QUARTER FROM date) = 1 THEN spent_amount ELSE 0 END) → q1_spent, SUM(CASE WHEN EXTRACT(QUARTER FROM date) = 2 THEN spent_amount ELSE 0 END) → q2_spent, SUM(CASE WHEN EXTRACT(QUARTER FROM date) = 3 THEN spent_amount ELSE 0 END) → q3_spent, SUM(CASE WHEN EXTRACT(QUARTER FROM date) = 4 THEN spent_amount ELSE 0 END) → q4_spent,,(F)
-
-Подредување по име на сметка:
-- R_final ← τ,,account_name,,(G)
-https://develop.finki.ukim.mk/projects/FEIN/wiki/AdvancedReport23
-==== Годишни трендови на трансакции за корисник
-Трошења по квартали за тековната година за одреден корисник
-{{{#!sql
-CREATE OR REPLACE FUNCTION get_quarterly_spending_trends_user(
-    p_user_id INT
-)
-RETURNS TABLE (
-    account_name TEXT,
-    q1_spent NUMERIC,
-    q2_spent NUMERIC,
-    q3_spent NUMERIC,
-    q4_spent NUMERIC
-)
-LANGUAGE plpgsql
-AS $$
-BEGIN
-    RETURN QUERY
-    SELECT 
-        ta.account_name, 
-        SUM(CASE WHEN EXTRACT(QUARTER FROM t.date) = 1 THEN tb.spent_amount ELSE 0 END) AS q1_spent,
-        SUM(CASE WHEN EXTRACT(QUARTER FROM t.date) = 2 THEN tb.spent_amount ELSE 0 END) AS q2_spent,
-        SUM(CASE WHEN EXTRACT(QUARTER FROM t.date) = 3 THEN tb.spent_amount ELSE 0 END) AS q3_spent,
-        SUM(CASE WHEN EXTRACT(QUARTER FROM t.date) = 4 THEN tb.spent_amount ELSE 0 END) AS q4_spent
-    FROM transaction_breakdown tb
-    JOIN transaction t 
-        ON tb.transaction_id = t.transaction_id
-    JOIN transaction_account ta 
-        ON tb.transaction_account_id = ta.transaction_account_id
-    WHERE ta.user_id = p_user_id
-      AND EXTRACT(YEAR FROM t.date) = EXTRACT(YEAR FROM CURRENT_DATE)
-    GROUP BY ta.account_name
-    ORDER BY ta.account_name;
-END;
-$$;
-}}}
-
-==== Релациона алгебра
-- TA(transaction_account_id, user_id, account_name)
-- TB(transaction_id, transaction_account_id, spent_amount)
-- T(transaction_id, date)
-
-JOIN на табелите:
-- J1 ← TA ⨝,,TA.transaction_account_id = TB.transaction_account_id,, TB
-- J2 ← J1 ⨝,,TB.transaction_id = T.transaction_id,, T
-
-Филтрирање по корисник и тековна година:
-- F ← σ,,user_id = p_user_id ∧ EXTRACT(YEAR FROM date) = EXTRACT(YEAR FROM CURRENT_DATE),,(J2)
-
-Агрегација по квартали:
-- G ← γ,,account_name; SUM(CASE WHEN EXTRACT(QUARTER FROM date) = 1 THEN spent_amount ELSE 0 END) → q1_spent, SUM(CASE WHEN EXTRACT(QUARTER FROM date) = 2 THEN spent_amount ELSE 0 END) → q2_spent, SUM(CASE WHEN EXTRACT(QUARTER FROM date) = 3 THEN spent_amount ELSE 0 END) → q3_spent, SUM(CASE WHEN EXTRACT(QUARTER FROM date) = 4 THEN spent_amount ELSE 0 END) → q4_spent,,(F)
-
-Подредување по име на сметка:
-- R_final ← τ,,account_name,,(G)
-https://develop.finki.ukim.mk/projects/FEIN/wiki/AdvancedReport24
-==== Неактивни тагови
-Идентификување тагови кои не биле користени во изминатиот месец
-{{{#!sql
-CREATE OR REPLACE FUNCTION get_inactive_tags_last_month()
-RETURNS TABLE (
-    tag_name TEXT
-)
-LANGUAGE plpgsql
-AS $$
-BEGIN
-    RETURN QUERY
-    SELECT 
-        tg.tag_name
-    FROM tag tg
-    LEFT JOIN tag_assigned_to_transaction tat 
-        ON tg.tag_id = tat.tag_id
-    LEFT JOIN transaction t 
-        ON tat.transaction_id = t.transaction_id
-    WHERE t.date IS NULL OR t.date < NOW() - INTERVAL '1 MONTH';
-END;
-$$;
-}}}
-
-==== Релациона алгебра
-- TG(tag_id, tag_name)
-- TAT(tag_id, transaction_id)
-- T(transaction_id, date)
-
-LEFT JOIN на табелите:
-- J1 ← TG ⟕,,TG.tag_id = TAT.tag_id,, TAT
-- J2 ← J1 ⟕,,TAT.transaction_id = T.transaction_id,, T
-
-Филтрирање на неактивни тагови:
-- R_final ← σ,,date IS NULL ∨ date < NOW() - INTERVAL '1 MONTH',,(J2)
-https://develop.finki.ukim.mk/projects/FEIN/wiki/AdvancedReportsVersion1
-= Напредни извештаи од базата (SQL и складирани процедури)
-
-===== Листа на трансакции кои го надминале балансот на една сметка подредени хронолошки
-{{{#!sql
-WITH CumulativeBalances AS (
-    SELECT 
-        t.transaction_id,
-        t.transaction_name,
-        t.date AS transaction_date,
-        ta.account_name,
-        u.user_id,
-        u.user_name,
-        tb.spent_amount AS transaction_amount,
-        SUM(tb.earned_amount - tb.spent_amount) OVER (
-            PARTITION BY ta.transaction_account_id
-            ORDER BY t.date
-        ) AS calculated_balance
-    FROM 
-        transaction_account ta
-    JOIN 
-        user u ON ta.user_id = u.user_id
-    JOIN 
-        transaction_breakdown tb ON ta.transaction_account_id = tb.transaction_account_id
-    JOIN 
-        transaction t ON tb.transaction_id = t.transaction_id
-    WHERE 
-        ta.account_name = 'Specific Account Name' -- Може да се замени со специфично име на акаунт
-)
-SELECT 
-    user_id,
-    user_name,
-    account_name,
-    transaction_id,
-    transaction_name,
-    transaction_amount,
-    transaction_date,
-    calculated_balance
-FROM 
-    CumulativeBalances
-WHERE 
-    transaction_amount > calculated_balance -- Трансакцијата го надминува пресметаниот баланс
-    AND transaction_amount > 0
-ORDER BY 
-    user_id, account_name, transaction_date DESC;
-}}}
-
-===== Надминување на баланс на една сметка со трансакција во сегашно време
-{{{#!sql
-SELECT 
-    u.user_id,
-    u.user_name,
-    ta.account_name,
-    ta.balance AS current_balance,
-    t.transaction_id,
-    t.transaction_name,
-    tb.spent_amount AS transaction_amount,
-    t.date AS transaction_date
-FROM 
-    transaction_account ta
-JOIN user u ON ta.user_id = u.user_id
-JOIN transaction_breakdown tb ON ta.transaction_account_id = tb.transaction_account_id
-JOIN transaction t ON tb.transaction_id = t.transaction_id
-WHERE 
-    tb.spent_amount > ta.balance -- Трансакцијата го надминува моменталниот баланс на СМЕТКАТА
-    AND tb.spent_amount > 0
-ORDER BY 
-    u.user_id, ta.account_name, t.date DESC;
-}}}
-
-===== Листа на трансакции кои го надминале балансот на сите сметки вкупно подредени хронолошки
-{{{#!sql
-WITH CumulativeTotalBalances AS (
-    SELECT 
-        t.transaction_id,
-        t.transaction_name,
-        t.date AS transaction_date,
-        u.user_id,
-        u.user_name,
-        tb.spent_amount AS transaction_amount,
-        SUM(tb.earned_amount - tb.spent_amount) OVER (
-            PARTITION BY u.user_id
-            ORDER BY t.date
-        ) AS calculated_total_balance
-    FROM 
-        transaction_account ta
-    JOIN 
-        user u ON ta.user_id = u.user_id
-    JOIN 
-        transaction_breakdown tb ON ta.transaction_account_id = tb.transaction_account_id
-    JOIN 
-        transaction t ON tb.transaction_id = t.transaction_id
-)
-SELECT 
-    user_id,
-    user_name,
-    transaction_id,
-    transaction_name,
-    transaction_amount,
-    transaction_date,
-    calculated_total_balance
-FROM 
-    CumulativeTotalBalances
-WHERE 
-    transaction_amount > calculated_total_balance -- Трансакцијата го надминува пресметаниот вкупен баланс од сите сметки
-    AND transaction_amount > 0
-ORDER BY 
-    user_id, transaction_date DESC;
-}}}
-
-===== Надминување на вкупниот баланс на сите сметки на корисникот во сегашно време
-Овде ќе го собереме моменталниот баланс на сите сметки на корисникот и ќе провериме дали сумата на моменталната трансакцијата ја надминува таа вкупна вредност
-{{{#!sql
-WITH total_user_balance AS (
-    SELECT 
-        u.user_id,
-        SUM(ta.balance) AS total_balance
-    FROM 
-        transaction_account ta
-    JOIN user u ON ta.user_id = u.user_id
-    GROUP BY 
-        u.user_id
-)
-SELECT 
-    u.user_id,
-    u.user_name,
-    SUM(tb.spent_amount) AS total_transaction_amount,
-    tub.total_balance AS user_total_balance
-FROM 
-    transaction_account ta
-JOIN user u ON ta.user_id = u.user_id
-JOIN transaction_breakdown tb ON ta.transaction_account_id = tb.transaction_account_id
-JOIN transaction t ON tb.transaction_id = t.transaction_id
-JOIN total_user_balance tub ON u.user_id = tub.user_id
-WHERE 
-    t.date <= CURRENT_DATE -- Само активни трансакции до денешен датум
-GROUP BY 
-    u.user_id, u.user_name, tub.total_balance
-HAVING 
-    SUM(tb.spent_amount) > tub.total_balance -- Трансакциите го надминуваат ВКУПНИОТ моментален баланс
-ORDER BY 
-    u.user_id;
-}}}
-
-===== Сумарни приходи и расходи по месеци
-Сумарните приходи и расходи (состојба) во целиот систем, поделени по месеци, со цел да се видат проблематичните месеци, се пресметува вкупниот приход и расход за секој месец, а разликата меѓу нив ќе ја даде состојбата
-{{{#!sql
-SELECT 
-    TO_CHAR(t.date, 'YYYY-MM') AS month, -- Форматирање на датумот во формат "Година-Месец"
-    SUM(CASE WHEN tb.earned_amount > 0 THEN tb.earned_amount ELSE 0 END) AS total_income, -- Вкупен приход
-    SUM(CASE WHEN tb.spent_amount > 0 THEN tb.spent_amount ELSE 0 END) AS total_expense, -- Вкупен расход
-    SUM(CASE WHEN tb.earned_amount > 0 THEN tb.earned_amount ELSE 0 END) -
-    SUM(CASE WHEN tb.spent_amount > 0 THEN tb.spent_amount ELSE 0 END) AS net_balance -- Нето состојба
-FROM 
-    transaction_breakdown tb
-JOIN 
-    transaction t ON tb.transaction_id = t.transaction_id
-GROUP BY 
-    TO_CHAR(t.date, 'YYYY-MM') -- Групирање по месец
-ORDER BY 
-    month;
-}}}
-Проблематичен месец е месец каде што {{{net_balance}}} е негативен. Резултатите можат да се прикажат во графикон (на пример, бар график или линиски график) за подобра анализа. Може да се додаде процентуална промена меѓу месеците за да се откријат трендовите во приходите и трошоците
-
-===== Број на трансакции што ги надминуваат приходите на сметка
-{{{#!sql
-WITH CumulativeBalances AS (
-    SELECT 
-        t.transaction_id,
-        ta.account_name,
-        SUM(tb.earned_amount - tb.spent_amount) OVER (
-            PARTITION BY ta.transaction_account_id
-            ORDER BY t.date
-        ) AS calculated_balance,
-        tb.spent_amount
-    FROM 
-        transaction_account ta
-    JOIN 
-        transaction_breakdown tb ON ta.transaction_account_id = tb.transaction_account_id
-    JOIN 
-        transaction t ON tb.transaction_id = t.transaction_id
-)
-SELECT 
-    account_name, 
-    COUNT(transaction_id) AS transactions_exceeding_balance
-FROM 
-    CumulativeBalances
-WHERE 
-    spent_amount > calculated_balance -- Кога потрошениот износ го надминува пресметаниот баланс
-    AND spent_amount > 0
-GROUP BY 
-    account_name
-ORDER BY 
-    transactions_exceeding_balance DESC;
-}}}
-Ако сакаме да ги добиеме вкупните трансакции кои ги надминуваат средствата низ целиот систем, без групирање по сметки:
-{{{#!sql
-WITH CumulativeTotalBalances AS (
-    SELECT 
-        t.transaction_id,
-        u.user_id,
-        SUM(tb.earned_amount - tb.spent_amount) OVER (
-            PARTITION BY u.user_id
-            ORDER BY t.date
-        ) AS calculated_total_balance,
-        tb.spent_amount
-    FROM 
-        transaction_account ta
-    JOIN 
-        user u ON ta.user_id = u.user_id
-    JOIN 
-        transaction_breakdown tb ON ta.transaction_account_id = tb.transaction_account_id
-    JOIN 
-        transaction t ON tb.transaction_id = t.transaction_id
-)
-SELECT 
-    COUNT(transaction_id) AS total_transactions_exceeding_balance
-FROM 
-    CumulativeTotalBalances
-WHERE 
-    spent_amount > calculated_total_balance -- Кога потрошениот износ го надминува кумулативниот баланс
-    AND spent_amount > 0;
-}}}
-
-===== Сумарни податоци за тагови
-Сумирани податоци каде редовите се месеци, а колоните се тагови, а во полината се покажуваат различни сумирани податоци:
- - Вкупен број на трансакции
-{{{#!sql
-DO $$
-DECLARE
-    col_list TEXT; -- Листа на тагови за изведување на пивот
-    dynamic_query TEXT; -- Динамичко SQL прашање
-BEGIN
-    -- Чекор 1: Динамички се генерира листа на колони
-    SELECT STRING_AGG(DISTINCT format('SUM(CASE WHEN tg.tag_name = %L THEN 1 ELSE 0 END) AS %I', tag_name, tag_name), ', ')
-    INTO col_list
-    FROM tag;
-
-    -- Чекор 2: Се гради динамичкото прашање
-    dynamic_query := format(
-        'SELECT 
-            TO_CHAR(t.date, ''YYYY-MM'') AS transaction_month,
-            %s
-        FROM 
-            transaction t
-        JOIN 
-            tag_assigned_to_transaction tat ON t.transaction_id = tat.transaction_id
-        JOIN 
-            tag tg ON tat.tag_id = tg.tag_id
-        GROUP BY 
-            transaction_month
-        ORDER BY 
-            transaction_month;',
-        col_list
-    );
-
-    -- Чекор 3: Извршување на динамичкото прашање
-    EXECUTE dynamic_query;
-END $$;
-}}}
- - Вкупно примени средства
-{{{#!sql
-DO $$
-DECLARE
-    col_list TEXT; -- Листа на тагови за изведување на пивот
-    dynamic_query TEXT; -- Динамичко SQL прашање
-BEGIN
-    -- Чекор 1: Динамички се генерира листа на колони
-    SELECT STRING_AGG(DISTINCT format('SUM(CASE WHEN tg.tag_name = %L THEN tb.earned_amount ELSE 0 END) AS %I', tag_name, tag_name), ', ')
-    INTO col_list
-    FROM tag;
-
-    -- Чекор 2: Се гради динамичкото прашање
-    dynamic_query := format(
-        'SELECT 
-            TO_CHAR(t.date, ''YYYY-MM'') AS transaction_month,
-            %s
-        FROM 
-            transaction t
-        JOIN 
-            transaction_breakdown tb ON t.transaction_id = tb.transaction_id
-        JOIN 
-            tag_assigned_to_transaction tat ON t.transaction_id = tat.transaction_id
-        JOIN 
-            tag tg ON tat.tag_id = tg.tag_id
-        GROUP BY 
-            transaction_month
-        ORDER BY 
-            transaction_month;',
-        col_list
-    );
-
-    -- Чекор 3: Извршување на динамичкото прашање
-    EXECUTE dynamic_query;
-END $$;
-}}}
- - Вкупно потрошени средства
-{{{#!sql
-DO $$
-DECLARE
-    col_list TEXT; -- Листа на тагови за изведување на пивот
-    dynamic_query TEXT; -- Динамичко SQL прашање
-BEGIN
-    -- Чекор 1: Динамички се генерира листа на колони
-    SELECT STRING_AGG(DISTINCT format('SUM(CASE WHEN tg.tag_name = %L THEN tb.spent_amount ELSE 0 END) AS %I', tag_name, tag_name), ', ')
-    INTO col_list
-    FROM tag;
-
-    -- Чекор 2: Се гради динамичкото прашање
-    dynamic_query := format(
-        'SELECT 
-            TO_CHAR(t.date, ''YYYY-MM'') AS transaction_month,
-            %s
-        FROM 
-            transaction t
-        JOIN 
-            transaction_breakdown tb ON t.transaction_id = tb.transaction_id
-        JOIN 
-            tag_assigned_to_transaction tat ON t.transaction_id = tat.transaction_id
-        JOIN 
-            tag tg ON tat.tag_id = tg.tag_id
-        GROUP BY 
-            transaction_month
-        ORDER BY 
-            transaction_month;',
-        col_list
-    );
-
-    -- Чекор 3: Извршување на динамичкото прашање
-    EXECUTE dynamic_query;
-END $$;
-}}}
-
-===== Просечно трошење во последните 3 дена за цел систем
-Просечно дневно трошење за последните три дена, земајќи ги предвид сите трансакции:
-{{{#!sql
-SELECT 
-    AVG(daily_spending) AS average_spending_last_3_days
-FROM (
-    SELECT 
-        t.date::date AS transaction_date, 
-        SUM(tb.spent_amount) AS daily_spending
-    FROM 
-        transaction t
-    JOIN 
-        transaction_breakdown tb ON t.transaction_id = tb.transaction_id
-    WHERE 
-        t.date >= CURRENT_DATE - INTERVAL '2 DAY'
-        AND t.date < CURRENT_DATE + INTERVAL '1 DAY'
-    GROUP BY 
-        t.date::date
-) daily_totals;
-}}}
-
-===== Просечно трошење во последните 3 дена за корисник
-Просечно дневно трошење за последните 3 дена за одреден корисник:
-{{{#!sql
-SELECT 
-    AVG(daily_spending) AS average_spending_last_3_days
-FROM (
-    SELECT 
-        t.date::date AS transaction_date, 
-        SUM(tb.spent_amount) AS daily_spending
-    FROM 
-        transaction t
-    JOIN transaction_breakdown tb ON t.transaction_id = tb.transaction_id
-    JOIN transaction_account ta ON tb.transaction_account_id = ta.transaction_account_id
-    WHERE 
-        ta.user_id = 101 -- ID на конкретниот корисник
-        AND t.date >= CURRENT_DATE - INTERVAL '2 DAY'
-        AND t.date < CURRENT_DATE + INTERVAL '1 DAY'
-    GROUP BY 
-        t.date::date
-) daily_totals;
-}}}
-
-===== Вкупно потрошено во тековниот месец за цел систем
-Вкупна сума на трошоци во тековниот месец
-{{{#!sql
-SELECT 
-    SUM(tb.spent_amount) AS total_spent
-FROM 
-    transaction_breakdown tb
-JOIN transaction t ON tb.transaction_id = t.transaction_id
-WHERE 
-    EXTRACT(MONTH FROM t.date) = EXTRACT(MONTH FROM CURRENT_DATE)
-    AND EXTRACT(YEAR FROM t.date) = EXTRACT(YEAR FROM CURRENT_DATE);
-}}}
-
-===== Вкупно потрошено во тековниот месец за корисник
-Вкупна сума на трошоци во тековниот месец на еден корисник
-{{{#!sql
-SELECT 
-    SUM(tb.spent_amount) AS total_spent
-FROM 
-    transaction_breakdown tb
-JOIN transaction t ON tb.transaction_id = t.transaction_id
-JOIN transaction_account ta ON tb.transaction_account_id = ta.transaction_account_id
-WHERE 
-    ta.user_id = 101 -- ID на конкретниот корисник
-    AND EXTRACT(MONTH FROM t.date) = EXTRACT(MONTH FROM CURRENT_DATE)
-    AND EXTRACT(YEAR FROM t.date) = EXTRACT(YEAR FROM CURRENT_DATE);
-}}}
-
-===== Дневен буџет до крајот на месецот за корисник
-Дневен буџет базиран на вкупниот баланс на сите сметки на корисникот
-{{{#!sql
-SELECT 
-    SUM(ta.balance) / (DATE_PART('day', DATE_TRUNC('month', CURRENT_DATE + INTERVAL '1 MONTH') - INTERVAL '1 DAY') - DATE_PART('day', CURRENT_DATE)) AS daily_budget
-FROM 
-    transaction_account ta
-WHERE 
-    ta.user_id = 101; -- ID на конкретниот корисник
-}}}
-
-===== Долг на кредитна картичка од минатиот месец за цел систем
-Вкупна сума потрошена од сметките обележани како „кредитна картичка“ за минатиот месец
-{{{#!sql
-SELECT 
-    SUM(tb.spent_amount) AS credit_card_debt
-FROM 
-    transaction_breakdown tb
-JOIN transaction_account ta ON tb.transaction_account_id = ta.transaction_account_id
-JOIN transaction t ON tb.transaction_id = t.transaction_id
-WHERE 
-    (ta.account_name ILIKE '%кредитна%' OR ta.account_name ILIKE '%credit%')
-    AND EXTRACT(MONTH FROM t.date) = EXTRACT(MONTH FROM CURRENT_DATE - INTERVAL '1 MONTH')
-    AND EXTRACT(YEAR FROM t.date) = EXTRACT(YEAR FROM CURRENT_DATE - INTERVAL '1 MONTH');
-}}}
-
-===== Долг на кредитна картичка од минатиот месец за корисник
-Потрошено од сметки означени како „кредитна картичка“ за минатиот месец за еден корисник
-{{{#!sql
-SELECT 
-    SUM(tb.spent_amount) AS credit_card_debt
-FROM 
-    transaction_breakdown tb
-JOIN transaction_account ta ON tb.transaction_account_id = ta.transaction_account_id
-JOIN transaction t ON tb.transaction_id = t.transaction_id
-WHERE 
-    ta.user_id = 101 -- ID на конкретниот корисник
-    AND (ta.account_name ILIKE '%кредитна%' OR ta.account_name ILIKE '%credit%')
-    AND EXTRACT(MONTH FROM t.date) = EXTRACT(MONTH FROM CURRENT_DATE - INTERVAL '1 MONTH')
-    AND EXTRACT(YEAR FROM t.date) = EXTRACT(YEAR FROM CURRENT_DATE - INTERVAL '1 MONTH');
-}}}
-
-===== Трендови на трошење според тагови за цел систем
-Трендови на трошење за секој таг во последните шест месеци
-{{{#!sql
-SELECT 
-    tg.tag_name, 
-    DATE_TRUNC('month', t.date) AS month,
-    SUM(tb.spent_amount) AS total_spent
-FROM 
-    tag tg
-JOIN tag_assigned_to_transaction tat ON tg.tag_id = tat.tag_id
-JOIN transaction t ON tat.transaction_id = t.transaction_id
-JOIN transaction_breakdown tb ON t.transaction_id = tb.transaction_id
-WHERE 
-    t.date >= DATE_TRUNC('month', CURRENT_DATE - INTERVAL '6 MONTHS')
-GROUP BY 
-    tg.tag_name, month
-ORDER BY 
-    tg.tag_name, month;
-}}}
-
-===== Трендови на трошење според тагови за корисник
-Сумирање на трошењата според тагови во последните 6 месеци за одреден корисник
-{{{#!sql
-SELECT 
-    tg.tag_name, 
-    DATE_TRUNC('month', t.date) AS month,
-    SUM(tb.spent_amount) AS total_spent
-FROM 
-    tag_assigned_to_transaction tat
-JOIN transaction t ON tat.transaction_id = t.transaction_id
-JOIN transaction_breakdown tb ON t.transaction_id = tb.transaction_id
-JOIN transaction_account ta ON tb.transaction_account_id = ta.transaction_account_id
-JOIN tag tg ON tat.tag_id = tg.tag_id
-WHERE 
-    ta.user_id = 101 -- ID на конкретниот корисник
-    AND t.date >= DATE_TRUNC('month', CURRENT_DATE - INTERVAL '6 MONTHS')
-GROUP BY 
-    tg.tag_name, month
-ORDER BY 
-    tg.tag_name, month;
-}}}
-
-===== Вкупно трошење според тагови
-Вкупно трошење групирано според тагови за тековниот месец
-{{{#!sql
-SELECT 
-    tg.tag_name,
-    SUM(tb.spent_amount) AS total_spent
-FROM 
-    tag tg
-JOIN tag_assigned_to_transaction tat ON tg.tag_id = tat.tag_id
-JOIN transaction t ON tat.transaction_id = t.transaction_id
-JOIN transaction_breakdown tb ON t.transaction_id = tb.transaction_id
-WHERE 
-    EXTRACT(MONTH FROM t.date) = EXTRACT(MONTH FROM CURRENT_DATE)
-    AND EXTRACT(YEAR FROM t.date) = EXTRACT(YEAR FROM CURRENT_DATE)
-GROUP BY 
-    tg.tag_name
-ORDER BY 
-    total_spent DESC;
-}}}
-
-===== Сметки со највисоко вкупно трошење во изминатата година
-{{{#!sql
-SELECT 
-    ta.account_name, 
-    SUM(tb.spent_amount) AS total_spent
-FROM 
-    transaction_breakdown tb
-JOIN transaction t ON tb.transaction_id = t.transaction_id
-JOIN transaction_account ta ON tb.transaction_account_id = ta.transaction_account_id
-WHERE 
-    t.date >= NOW() - INTERVAL '1 YEAR'
-GROUP BY 
-    ta.account_name
-ORDER BY 
-    total_spent DESC
-LIMIT 10;
-}}}
-
-===== Најчести времиња за трансакции
-Во кој час од денот корисниците најчесто вршат трансакции
-{{{#!sql
-SELECT 
-    EXTRACT(HOUR FROM t.date) AS transaction_hour, 
-    COUNT(*) AS transaction_count
-FROM 
-    transaction t
-GROUP BY 
-    transaction_hour
-ORDER BY 
-    transaction_count DESC;
-}}}
-
-===== Трансакции според тагови со највисоко трошење за цел систем
-{{{#!sql
-SELECT 
-    tg.tag_name, 
-    SUM(tb.spent_amount) AS total_spent
-FROM 
-    transaction_breakdown tb
-JOIN transaction t ON tb.transaction_id = t.transaction_id
-JOIN tag_assigned_to_transaction tat ON t.transaction_id = tat.transaction_id
-JOIN tag tg ON tat.tag_id = tg.tag_id
-GROUP BY 
-    tg.tag_name
-ORDER BY 
-    total_spent DESC;
-}}}
-
-===== Трансакции според тагови со највисоко трошење за корисник
-Сумирање на трошењата според тагови за одреден корисник
-{{{#!sql
-SELECT 
-    tg.tag_name, 
-    SUM(tb.spent_amount) AS total_spent
-FROM 
-    transaction_breakdown tb
-JOIN transaction t ON tb.transaction_id = t.transaction_id
-JOIN transaction_account ta ON tb.transaction_account_id = ta.transaction_account_id
-JOIN tag_assigned_to_transaction tat ON t.transaction_id = tat.transaction_id
-JOIN tag tg ON tat.tag_id = tg.tag_id
-WHERE 
-    ta.user_id = 101 -- ID на конкретниот корисник
-GROUP BY 
-    tg.tag_name
-ORDER BY 
-    total_spent DESC;
-}}}
-
-===== Годишни трендови на трансакции за цел систем
-Трошења според сметки за секој квартал од тековната година
-{{{#!sql
-SELECT 
-    ta.account_name, 
-    SUM(CASE WHEN EXTRACT(QUARTER FROM t.date) = 1 THEN tb.spent_amount ELSE 0 END) AS q1_spent,
-    SUM(CASE WHEN EXTRACT(QUARTER FROM t.date) = 2 THEN tb.spent_amount ELSE 0 END) AS q2_spent,
-    SUM(CASE WHEN EXTRACT(QUARTER FROM t.date) = 3 THEN tb.spent_amount ELSE 0 END) AS q3_spent,
-    SUM(CASE WHEN EXTRACT(QUARTER FROM t.date) = 4 THEN tb.spent_amount ELSE 0 END) AS q4_spent
-FROM 
-    transaction_breakdown tb
-JOIN transaction t ON tb.transaction_id = t.transaction_id
-JOIN transaction_account ta ON tb.transaction_account_id = ta.transaction_account_id
-WHERE 
-    EXTRACT(YEAR FROM t.date) = EXTRACT(YEAR FROM CURRENT_DATE)
-GROUP BY 
-    ta.account_name
-ORDER BY 
-    ta.account_name;
-}}}
-
-===== Годишни трендови на трансакции за корисник
-Трошења по квартали за тековната година за одреден корисник
-{{{#!sql
-SELECT 
-    ta.account_name, 
-    SUM(CASE WHEN EXTRACT(QUARTER FROM t.date) = 1 THEN tb.spent_amount ELSE 0 END) AS q1_spent,
-    SUM(CASE WHEN EXTRACT(QUARTER FROM t.date) = 2 THEN tb.spent_amount ELSE 0 END) AS q2_spent,
-    SUM(CASE WHEN EXTRACT(QUARTER FROM t.date) = 3 THEN tb.spent_amount ELSE 0 END) AS q3_spent,
-    SUM(CASE WHEN EXTRACT(QUARTER FROM t.date) = 4 THEN tb.spent_amount ELSE 0 END) AS q4_spent
-FROM 
-    transaction_breakdown tb
-JOIN transaction t ON tb.transaction_id = t.transaction_id
-JOIN transaction_account ta ON tb.transaction_account_id = ta.transaction_account_id
-WHERE 
-    ta.user_id = 101 -- ID на конкретниот корисник
-    AND EXTRACT(YEAR FROM t.date) = EXTRACT(YEAR FROM CURRENT_DATE)
-GROUP BY 
-    ta.account_name
-ORDER BY 
-    ta.account_name;
-}}}
-
-===== Неактивни тагови
-Идентификување тагови кои не биле користени во изминатиот месец
-{{{#!sql
-SELECT 
-    tg.tag_name
-FROM 
-    tag tg
-LEFT JOIN tag_assigned_to_transaction tat ON tg.tag_id = tat.tag_id
-LEFT JOIN transaction t ON tat.transaction_id = t.transaction_id
-WHERE 
-    t.date IS NULL OR t.date < NOW() - INTERVAL '1 MONTH';
-}}}
-
-https://develop.finki.ukim.mk/projects/FEIN/wiki/PrototypeApplication
-= Прототип апликација
-
-== Презентација на финалниот изглед на апликацијата
-[wiki:Presentation Презентација]
-
-== Инструкции за стартување на апликацијата
-[wiki:Instructions Инструкции]
-
-
-https://develop.finki.ukim.mk/projects/FEIN/wiki/Presentation
-== Опис на финална имплементација со функционалности и насоки за употреба
-
-Финалната верзија на апликацијата ги опфаќа сите сценарија од фаза Ф3, а тоа се:
-- Регистрација на корисник
-- Најава на корисник
-- Додавање на трансакциска сметка
-- Преглед на сметки
-- Додавање на трансакција
-- Преглед на трансакции
-- Ажурирање/бришење трансакции
-- Извештаи (годишни/месечни/по тагови/графици)
-
-== Опис на секое од наведените сценарија
-=== Регистрација на корисник
-[https://youtu.be/9s8dOSDpf38 видео]
-=== Најава на корисник
-[https://youtu.be/elvXAcs-mVA видео]
-=== Додавање на трансакциска сметка
-[https://youtu.be/jfItnKE0whg видео]
-=== Преглед на сметки
-[https://youtu.be/DxqRu2akhJ0 видео]
-=== Додавање на трансакција
-[https://youtu.be/BxtTVXZDi2A видео]
-=== Преглед на трансакции
-[https://youtu.be/jZQE1guSBPo видео]
-=== Ажурирање трансакции
-[https://youtu.be/vxQXTLj2HgE видео]
-=== Бришење трансакции
-[https://youtu.be/pET4XEN1pxM видео]
-=== Извештаи (годишни/месечни/по тагови/графици)
-[https://youtu.be/bkKTstZj0uU видео]
-
-https://develop.finki.ukim.mk/projects/FEIN/wiki/Instructions
-== Упатство за стартување и користење на апликацијата (Верзија 2)
-
-Препорачувам користење на Visual Studio Code.
-Потребно е да се инсталира Python, истото може да се направи [https://www.python.org/downloads/ овде].
-
-Во VS Code, препорачувам користење на виртуелна околина, пример //venv//. Повеќе на [https://docs.python.org/3/library/venv.html линков].
-
-Откако ќе се активира виртуелна околина, може да се инсталираат потребните python пакети во нејзе. Тие се наоѓаат во текстуелната датотека //requirements.txt//. За да се инсталираат сите одеднаш, може да се искорсти командата: 
-{{{#!cmd
-pip install -r requirements.txt
-}}}
-
-Кога сѐ од горенаведеното ќе се направи, може да се отворат две //bash// терминали во VS Code кои ќе се постават една до друга (root е root-от на проектот):
-- Во првата терминала се пишува командата
-{{{#!cmd
-uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
-}}}
-    со неа се стартува backend-от кој се поврзува автоматски со датабазата на проектот дадена од професорот.
-- Во втората терминала се пишува командата
-{{{#!cmd
-python -m cli.cli_app
-}}}
-    со неа се извршува прототип апликацијата која се наоѓа во //cli// директориумот под името //cli_app//.
-
-Во втората терминала може да се почне со користење на прототип апликацијата, притоа секое повикување и користење на backend-от и базата се отсликува во првата терминала. 
-
-=== Верзии
-[wiki:InstructionsVersion1 Верзија 1]
-
-https://develop.finki.ukim.mk/projects/FEIN/wiki/InstructionsVersion1
-== Упатство за стартување и користење на апликацијата (Верзија 1)
-
-Препорачувам користење на Visual Studio Code.
-Потребно е да се инсталира Python, истото може да се направи [https://www.python.org/downloads/ овде].
-
-Во VS Code, препорачувам користење на виртуелна околина, пример //venv//. Повеќе на [https://docs.python.org/3/library/venv.html линков].
-
-Откако ќе се активира виртуелна околина, може да се инсталираат потребните python пакети во нејзе. Тие се наоѓаат во текстуелната датотека //requirements.txt//. За да се инсталираат сите одеднаш, може да се искорсти командата: 
-{{{#!cmd
-pip install -r requirements.txt
-}}}
-
-Јас користев Docker Containers за извршување и поврзување на прототип апликацијата со локална датабаза заедно. За тоа потребно е да се инсталира Docker Desktop, инструкции за тоа на [https://docs.docker.com/desktop/setup/install/windows-install/ линков].
-
-Кога сѐ од горенаведеното ќе се направи, може да се отворат две //bash// терминали во VS Code кои ќе се постават една до друга (root е root-от на проектот):
-- Во првата терминала се пишува командата
-{{{#!cmd
-docker-compose up --build
-}}}
-    со неа се build-аат и подигаат Docker контејнерите. Првиот пат, процесот ќе потрае подолго, бидејќи треба да ги симне и инсталира Docker сликите, истото да го направи со python пакетите и сето тоа да го из-build-а.
-- Во втората терминала се пишува командата
-{{{#!cmd
-python -m cli.cli_app
-}}}
-    со неа се извршува прототип апликацијата која се наоѓа во //cli// директориумот под името //cli_app//.
-
-Во втората терминала може да се почне со користење на прототип апликацијата, притоа секое повикување и користење на backend-от и базата се отсликува во првата терминала.
-
-
-https://develop.finki.ukim.mk/projects/FEIN/wiki/Normalization
-= Нормализација
-
-== Денормализирана форма
-
-Форма во кој има една табела во која се внесени сите ентитети и нивни релации помеѓу себе, без никакви правила
-
-||= //user_id =||= //user_name =||= //email =||= //password =||= //transaction_account_id =||= //account_name =||= //balance =||= //transaction_id =||= //transaction_name =||= //amount =||= //net_amount =||= //date =||= //transaction_breakdown_id =||= //spent_amount =||= //earned_amount =||= //tag_id =||= //tag_name =||
-|| 1|| james || james@fein.com || $2y$10$rcqLdIzMcYfmGFWCP3kix.JNTOzjIp0xVehMd11wzaanAXUDFLQMu || 1, 2 || credit card, cash || 5000, 1200 || 1, 2 || electricity bill, burger || -1800, -800 || -1800, -200 || 18:03:25 Sep 26, 2025, 12:43:00 Sep 26, 2025 || 1,2 || -1800, -800 || 0, 600 || 1, 2 || bills, food ||
-|| 2|| anita || anita@fein.com || $2y$10$CsSsqA.FFhBR/TWCZCUWYOPxYA.HmGb7ULQPgJGhv3vQS2xRqluYm || 1 || debit card || 3200 || 1 || nail polish || -200 || -200 || 19:31:32 Sep 12 2025 || 1 || -200 || 0 || 1 || beauty ||
-
-Во денормализираната табела постојат следниве основни функционални зависности:
-
-- {user_id} → {user_name, email, password}
-- {transaction_account_id} → {account_name, balance, user_id}
-- {transaction_id} → {transaction_name, amount, net_amount, date}
-- {transaction_breakdown_id} → {transaction_id, transaction_account_id, spent_amount, earned_amount}
-- {tag_id} → {tag_name}
-
-== Прва нормална форма
-
-Форма во која повторно има една табела, но овојпат, таа е ограничена со следниве работи:
-- Подредувањето на редовите не претставува никакво значење
-- Во ќелиите на секоја колона има по една вредност
-- Не се мешаат типови на податоци во една ќелија
-- Табелата има примарен композитен клуч(user_id, transaction_id, transaction_account_id, transaction_breakdown_id)
-- Нема повторувачки групи, сите што биле во денормализираната форма сега се во посебен ред 
-
-==== R
-||= //user_id =||= //user_name =||= //email =||= //password =||= //transaction_account_id =||= //account_name =||= //balance =||= //transaction_id =||= //transaction_name =||= //amount =||= //net_amount =||= //date =||= //transaction_breakdown_id =||= //spent_amount =||= //earned_amount =||= //tag_id =||= //tag_name =||
-|| 1|| james || james@fein.com || $2y$10$rcqLdIzMcYfmGFWCP3kix.JNTOzjIp0xVehMd11wzaanAXUDFLQMu || 1|| credit card || 5000|| 1|| electricity bill || -1800|| -1800|| 18:03:25 Sep 26, 2025 || 1|| -1800|| 0|| 1|| bills ||
-|| 1|| james || james@fein.com || $2y$10$rcqLdIzMcYfmGFWCP3kix.JNTOzjIp0xVehMd11wzaanAXUDFLQMu || 2|| cash || 1200|| 2|| burger || -800|| -200|| 12:43:00 Sep 26, 2025 || 2|| -800|| 600|| 2|| food ||
-|| 2|| anita || anita@fein.com || $2y$10$CsSsqA.FFhBR/TWCZCUWYOPxYA.HmGb7ULQPgJGhv3vQS2xRqluYm || 3|| debit card || 3200|| 3|| nail polish || -200|| -200|| 19:31:32 Sep 12 2025 || 3|| -200|| 0|| 3|| beauty ||
-
-==== Избор на примарен клуч во 1NF
-
-Во првата нормална форма е дефиниран композитен примарен клуч:
-//(user_id, transaction_id, transaction_account_id, transaction_breakdown_id)//
-
-tag_id може да биде вклучен во композитниот примарен клуч во 1NF бидејќи 1NF само ограничува по атомност и уникатност на ред, но не треба да биде вклучен бидејќи ја нарушува минималноста на клучот, додава парцијални зависности и погрешно ја моделира many-to-many релацијата.
-Затоа, tag_id мора да биде одделен во посебна релација во погорните нормални форми.
-
-Овој клуч е избран бидејќи eдинствено го идентификува секој ред, ги опфаќа сите повторувачки групи од денормализираната форма и не постои подмножество од овој клуч кое може самостојно да го идентификува редот.
-
-=== Премин од 1NF во 2NF
-Проблем се делумните зависности, во 1NF постојат атрибути кои зависат само од дел од примарниот клуч, на пример:
-- {user_id} → {user_name, email, password}
-- {transaction_id} → {transaction_name, amount, net_amount, date}
-
-Ова претставува кршење на втората нормална форма, решение за тоа е декомпозиција, односно секоја група на атрибути која зависи од дел од композитниот клуч се издвојува во посебна табела каде тој дел станува примарен клуч. Пример:
-- USER(user_id PK, user_name, email, password)
-
-== Втора нормална форма
-
-Форма која ги следи овие правила:
-- Веќе е во прва нормална форма
-- Секој од атрибутите кој не е клуч, зависи од целосниот примарен клуч - со тоа се спречуваат аномалии на внесување, бришење и менување
-
-==== R1
-
-{ user_id } → {user_name, email, password}
-
-||= USER =||=  =||=  =||=  =||
-||= //user_id =||= //user_name =||= //email =||= //password =||
-|| 1 || james || james@fein.com || $2y$10$rcqLdIzMcYfmGFWCP3kix.JNTOzjIp0xVehMd11wzaanAXUDFLQMu ||
-|| 2 || anita || anita@fein.com || $2y$10$CsSsqA.FFhBR/TWCZCUWYOPxYA.HmGb7ULQPgJGhv3vQS2xRqluYm ||
-
-====== Lossless join test
-
-R:
-||= //user_id =||= //user_name =||= //email =||= //password =||= //transaction_account_id =||= //account_name =||= //balance =||= //transaction_id =||= //transaction_name =||= //amount =||= //net_amount =||= //date =||= //transaction_breakdown_id =||= //spent_amount =||= //earned_amount =||= //tag_id =||= //tag_name =||
-R1:
-||= //user_id =||= //user_name =||= //email =||= //password =||
-
-R ∩ R1 = { user_id, user_name, email, password }
-
-Бидејќи {user_id} → {user_name, email, password}, а user_id ∈ (R ∩ R1), следи дека (R ∩ R1) → R1
-
-⇒ Декомпозицијата е lossless
-
-R1.1 = R - { user_name, email, password }
-
-==== R2 
-
-{transaction_account_id} → {account_name, balance, user_id}
-
-||= TRANSACTION_ACCOUNT =||=  =||=  =||= ||
-||= //transaction_account_id =||= //account_name =||= //balance =||= //user_id ||
-|| 1 || credit card || 5000 || 1 ||
-|| 2 || cash || 1200 || 1 ||
-|| 3 || debit card || 3200 || 2 ||
-
-====== Lossless join test
-
-R1.1:
-||= //user_id =||= //transaction_account_id =||= //account_name =||= //balance =||= //transaction_id =||= //transaction_name =||= //amount =||= //net_amount =||= //date =||= //transaction_breakdown_id =||= //spent_amount =||= //earned_amount =||= //tag_id =||= //tag_name =||
-R2:
-||= //transaction_account_id =||= //account_name =||= //balance =||= //user_id ||
-
-R1.1 ∩ R2 = { transaction_account_id, account_name, balance, user_id }
-
-Бидејќи {transaction_account_id} → {account_name, balance, user_id}, а transaction_account_id ∈ (R1.1 ∩ R2), следи дека (R1.1 ∩ R2) → R2
-
-⇒ Декомпозицијата е lossless
-
-R2.1 = R1.1 - { account_name, balance, user_id }
-
-==== R3
-
-{transaction_id} → {transaction_name, amount, net_amount, date, tag_id}
-
-{transaction_id} → {tag_id} → {tag_name}
-
-||= TRANSACTION_WITH_TAG_NAME =||=  =||=  =||=  =||=  =||=  =||=  =||
-||= //transaction_id =||= //transaction_name =||= //amount =||= //net_amount =||= //date =||= //tag_id =||= //tag_name =||
-|| 1 || electricity bill || -1800 || -1800 || 18:03:25 Sep 26, 2025 || 1 || bills ||
-|| 2 || burger || -800 || -200 || 12:43:00 Sep 26, 2025 || 2 || food ||
-|| 3 || nail polish || -200 || -200 || 19:31:32 Sep 12 2025 || 3 || beauty ||
-
-====== Lossless join test
-
-R2.1:
-||= //transaction_account_id =||= //transaction_id =||= //transaction_name =||= //amount =||= //net_amount =||= //date =||= //transaction_breakdown_id =||= //spent_amount =||= //earned_amount =||= //tag_id =||= //tag_name =||
-R3:
-||= //transaction_id =||= //transaction_name =||= //amount =||= //net_amount =||= //date =||= //tag_id =||= //tag_name =||
-
-R2.1 ∩ R3 = { transaction_id, transaction_name, amount, net_amount, date, tag_id, tag_name }
-
-Бидејќи {transaction_id} → {transaction_name, amount, net_amount, date, tag_id, tag_name}, а transaction_id ∈ (R2.1 ∩ R3), следи дека (R2.1 ∩ R3) → R3
-
-⇒ Декомпозицијата е lossless
-
-R3.1 = R2.1 - { transaction_name, amount, net_amount, date, tag_id, tag_name }
-
-==== R4
-
-{transaction_breakdown_id} → {transaction_id, transaction_account_id, spent_amount, earned_amount}
-
-||= TRANSACTION_BREAKDOWN =||=  =||=  =||=  =||=  =||
-||= //transaction_breakdown_id =||= //transaction_id =||= //transaction_account_id =||= //spent_amount =||= //earned_amount =||
-|| 1 || 1 || 1 || -1800 || 0 ||
-|| 2 || 2 || 2 || -800 || 600 ||
-|| 3 || 3 || 3 || -200 || 0 ||
-
-====== Lossless join test
-
-R3.1:
-||= //transaction_account_id =||= //transaction_id =||= //transaction_breakdown_id =||= //spent_amount =||= //earned_amount =||
-R4:
-||= //transaction_breakdown_id =||= //transaction_id =||= //transaction_account_id =||= //spent_amount =||= //earned_amount =||
-
-R3.1 ∩ R4 = { transaction_breakdown_id, transaction_id, transaction_account_id, spent_amount, earned_amount }
-
-Бидејќи {transaction_breakdown_id} → {transaction_id, transaction_account_id, spent_amount, earned_amount}, а transaction_breakdown_id ∈ (R3.1 ∩ R4), следи дека (R3.1 ∩ R4) → R4
-
-⇒ Декомпозицијата е lossless
-
-R4.1 = R3.1 - { transaction_breakdown_id, transaction_id, transaction_account_id, spent_amount, earned_amount }
-
-R4.1 = ∅
-
-=== Премин од 2NF во 3NF
-
-Во 2NF, за поедноставување, се разгледува случај со еден таг по трансакција, оваа претпоставка се отстранува при премин во 3NF.
-
-Проблем се транзитивните зависности, кои во 2NF сè уште постојат, на пример:
-- {transaction_id} → {tag_id} → {tag_name}
-
-Ова значи дека некои атрибути кои не се клучеви зависат од други атрибути кои исто така не се клучеви, што ја крши 3NF.
-
-Решение за ова е отстранување на транзитивноста, односно секој атрибут кој не зависи директно од примарниот клуч се издвојува во нова табела. Пример:
-- TAG(tag_id PK, tag_name)
-- TAG_ASSIGNED_TO_TRANSACTION(transaction_id PK, tag_id PK)
-
-== Трета нормална форма
-
-Формава ги следи овие правила:
-- Веќе е во втора нормална форма
-- Се отргнуваат транзитивните функционални зависности, со тоа се овозможува сите атрибути кои не се клучеви да зависат само од примарниот клуч и од никој друг атрибут
-
-==== R1
-
-{user_id} → {user_name, email, password}
-
-||= USER =||=  =||=  =||=  =||
-||= //user_id =||= //user_name =||= //email =||= //password =||
-|| 1 || james || james@fein.com || $2y$10$rcqLdIzMcYfmGFWCP3kix.JNTOzjIp0xVehMd11wzaanAXUDFLQMu ||
-|| 2 || anita || anita@fein.com || $2y$10$CsSsqA.FFhBR/TWCZCUWYOPxYA.HmGb7ULQPgJGhv3vQS2xRqluYm ||
-
-==== R2
-
-{transaction_account_id} → {account_name, balance, user_id}
-
-||= TRANSACTION_ACCOUNT =||=  =||=  =||=  =||
-||= //transaction_account_id =||= //account_name =||= //balance =||= //user_id =||
-|| 1 || credit card || 5000 || 1 ||
-|| 2 || cash || 1200 || 1 ||
-|| 3 || debit card || 3200 || 2 ||
-
-==== R3.2
-
-{transaction_id} → {transaction_name, amount, net_amount, date}
-
-||= TRANSACTION =||=  =||=  =||=  =||=  =||
-||= //transaction_id =||= //transaction_name =||= //amount =||= //net_amount =||= //date =||
-|| 1 || electricity bill || -1800 || -1800 || 18:03:25 Sep 26, 2025 ||
-|| 2 || burger || -800 || -200 || 12:43:00 Sep 26, 2025 ||
-|| 3 || nail polish || -200 || -200 || 19:31:32 Sep 12 2025 ||
-
-====== Lossless join test
-
-R3:
-||= //transaction_id =||= //transaction_name =||= //amount =||= //net_amount =||= //date =||= //tag_id =||= //tag_name =||
-R3.2:
-||= //transaction_id =||= //transaction_name =||= //amount =||= //net_amount =||= //date =||
-
-R3 ∩ R3.2 = { transaction_id, transaction_name, amount, net_amount, date }
-
-Бидејќи {transaction_id} → {transaction_name, amount, net_amount, date}, а transaction_id ∈ (R3 ∩ R3.2), следи дека (R3 ∩ R3.2) → R3.2
-
-⇒ Декомпозицијата е lossless
-
-R3.3 = R3 - { transaction_id, transaction_name, amount, net_amount, date }
-
-==== R3.4
-
-{tag_id} → {tag_name}
-
-||= TAG =||=  =||
-||= //tag_id =||= //tag_name =||
-|| 1 || bills ||
-|| 2 || food ||
-|| 3 || beauty ||
-
-====== Lossless join test
-
-R3.3:
-||= //tag_id =||= //tag_name =||
-R3.4:
-||= //tag_id =||= //tag_name =||
-
-R3.3 ∩ R3.4 = { tag_id, tag_name }
-
-Бидејќи {tag_id} → {tag_name}, а tag_id ∈ (R3.3 ∩ R3.4), следи дека (R3.3 ∩ R3.4) → R3.4
-
-⇒ Декомпозицијата е lossless
-
-R3.5 = R3.3 - { tag_id, tag_name }
-
-R3.5 = ∅
-
-==== R4
-
-{transaction_breakdown_id} → {transaction_id, transaction_account_id, spent_amount, earned_amount}
-
-||= TRANSACTION_BREAKDOWN =||=  =||=  =||=  =||=  =||
-||= //transaction_breakdown_id =||= //transaction_id =||= //transaction_account_id =||= //spent_amount =||= //earned_amount =||
-|| 1 || 1 || 1 || -1800 || 0 ||
-|| 2 || 2 || 2 || -800 || 600 ||
-|| 3 || 3 || 3 || -200 || 0 ||
-
-==== R5
-
-{transaction_id, tag_id} → {}
-
-||= TAG_ASSIGNED_TO_TRANSACTION =||=  =||
-||= //transaction_id =||= //tag_id =||
-|| 1 || 1 ||
-|| 2 || 2 ||
-|| 3 || 3 ||
-
-==== Избор на примарни клучеви во 3NF
-По правилото дека секој примарен клуч мора минимално и единствено да го идентификува редот.
-Примери:
-- USER.user_id — природен идентификатор
-- TRANSACTION.transaction_id — сурогатен клуч
-- TAG_ASSIGNED_TO_TRANSACTION(transaction_id, tag_id) — композитен клуч за M:N релација
-- TRANSACTION_BREAKDOWN.transaction_breakdown_id — сурогатен клуч
-
-==== Третата нормална форма погоре исто така е и BCNF
-
-=== 
-
-Сите функционални зависности можат да се проверат локално во поединечни табели, без потреба од JOIN операции, oва значи дека шемата е dependency-preserving, lossless-join и е во BCNF.
-
-== Заклучок
-Со примената на формална анализа на функционални зависности, правилен избор на примарни клучеви и проверка со lossless-join тест се елиминираат сите аномалии, се обезбедува логичка коректност и се добива шема погодна за имплементација во релациона датабаза.
-
-Финални табели:
-||= USER =||=  =||=  =||=  =||
-||= //user_id =||= //user_name =||= //email =||= //password =||
-|| 1 || james || james@fein.com || $2y$10$rcqLdIzMcYfmGFWCP3kix.JNTOzjIp0xVehMd11wzaanAXUDFLQMu ||
-|| 2 || anita || anita@fein.com || $2y$10$CsSsqA.FFhBR/TWCZCUWYOPxYA.HmGb7ULQPgJGhv3vQS2xRqluYm ||
-//
-||= TRANSACTION_ACCOUNT =||=  =||=  =||=  =||
-||= //transaction_account_id =||= //account_name =||= //balance =||= //user_id =||
-|| 1 || credit card || 5000 || 1 ||
-|| 2 || cash || 1200 || 1 ||
-|| 3 || debit card || 3200 || 2 ||
-//
-||= TRANSACTION =||=  =||=  =||=  =||=  =||
-||= //transaction_id =||= //transaction_name =||= //amount =||= //net_amount =||= //date =||
-|| 1 || electricity bill || -1800 || -1800 || 18:03:25 Sep 26, 2025 ||
-|| 2 || burger || -800 || -200 || 12:43:00 Sep 26, 2025 ||
-|| 3 || nail polish || -200 || -200 || 19:31:32 Sep 12 2025 ||
-//
-||= TAG =||=  =||
-||= //tag_id =||= //tag_name =||
-|| 1 || bills ||
-|| 2 || food ||
-|| 3 || beauty ||
-//
-||= TRANSACTION_BREAKDOWN =||=  =||=  =||=  =||=  =||
-||= //transaction_breakdown_id =||= //transaction_id =||= //transaction_account_id =||= //spent_amount =||= //earned_amount =||
-|| 1 || 1 || 1 || -1800 || 0 ||
-|| 2 || 2 || 2 || -800 || 600 ||
-|| 3 || 3 || 3 || -200 || 0 ||
-//
-||= TAG_ASSIGNED_TO_TRANSACTION =||=  =||
-||= //transaction_id =||= //tag_id =||
-|| 1 || 1 ||
-|| 2 || 2 ||
-|| 3 || 3 ||
-
-== 
-
-[wiki:NormalizationVer1 Верзија 1]
-https://develop.finki.ukim.mk/projects/FEIN/wiki/NormalizationVer1
-= Нормализација
-
-Ја дизајнирав базата на податоци со surrogate примарни клучеви(system-generated identifier) со една атрибутна колона (на пр. {{{user_id}}}, {{{transaction_id }}}) за едноставност и конзистентност. Вака, секој атрибут кој што не е клуч, директно зависи од примарниот клуч, што значи дека шемата ги исполнува 1NF, 2NF и 3NF/BCNF.
-
-Единствената намерна редундантност е колоната {{{net_amount}}} во {{{TRANSACTION}}}. Ја вклучив затоа што овозможува побрзи пребарувања и извештаи, иако е изведлива од {{{TRANSACTION_BREAKDOWN}}}. Ова беше свесен компромис заради перформанси.
-
-Кај many-to-many табелите ({{{TRANSACTION_BREAKDOWN}}} и {{{TAG_ASSIGNED_TO_TRANSACTION}}}) користам surrogate клучеви заради конзистентност на целата шема иако композитни клучеви би биле построго нормализирано решение, surrogate пристапот обезбедува унифицирана структура и јасност.
-
-=== Анализа по Табела
-==== 1. USER
-
-USER (**__user_id__**, **user_name**, **email**, **password**)
-
-{{{user_id → user_name, email, password}}}
-
-- 1NF: Сите атрибути се атомски, нема повторувачки групи.
-- 2NF: Сите табели имаат едноатрибутни surrogate примарни клучеви → нема делумни зависности.
-- 3NF/BCNF: Сите атрибути кои не се клучеви зависат само од примарниот клуч.
-Додадов UNIQUE ограничување на email затоа што е природен идентификатор.
-
-==== 2. TRANSACTION_ACCOUNT
-
-TRANSACTION_ACCOUNT (__**transaction_account_id**__, account_name, balance, user_id* (USER))
-
-{{{transaction_account_id → account_name, balance, user_id}}}
-
-- 1NF: Сите атрибути се атомски, нема повторувачки групи.
-- 2NF: Сите табели имаат едноатрибутни surrogate примарни клучеви → нема делумни зависности.
-- 3NF/BCNF: Сите атрибути кои не се клучеви зависат само од примарниот клуч. 
-
-==== 3. TRANSACTION
-
-TRANSACTION (__**transaction_id__**, transaction_name, **amount**, net_amount, **date**)
-
-{{{transaction_id → transaction_name, amount, net_amount, date}}}
-
-Атрибутот {{{net_amount}}} може да се изведе од {{{TRANSACTION_BREAKDOWN}}}. Го задржав намерно заради перформанси, за да избегнам постојано пресметување. За да нема неконзистентност, ќе се одржува преку triggers и апликациска логика.
-- 1NF: Сите атрибути се атомски, нема повторувачки групи.
-- 2NF: Сите табели имаат едноатрибутни surrogate примарни клучеви → нема делумни зависности.
-- 3NF/BCNF: Сите атрибути кои не се клучеви зависат само од примарниот клуч, со свесен компромис.
-
-==== 4. TRANSACTION_BREAKDOWN
-
-TRANSACTION_BREAKDOWN (__**transaction_breakdown_id__**, transaction_id* (TRANSACTION), transaction_account_id* (TRANSACTION_ACCOUNT), spent_amount, earned_amount)
-
-{{{transaction_breakdown_id → transaction_id, transaction_account_id, spent_amount, earned_amount}}}
-
-- 1NF: Сите атрибути се атомски, нема повторувачки групи.
-- 2NF: Сите табели имаат едноатрибутни surrogate примарни клучеви → нема делумни зависности.
-- 3NF/BCNF: Сите атрибути кои не се клучеви зависат само од примарниот клуч. 
-Го задржав surrogate примарниот клуч за унифициран пристап.
-
-==== 5. TAG
-
-TAG (__**tag_id__**, **tag_name**)
-
-{{{tag_id → tag_name}}}
-
-tag_name → tag_id бидејќи имињата на тагови се уникатни.
-
-- 1NF: Сите атрибути се атомски, нема повторувачки групи.
-- 2NF: Сите табели имаат едноатрибутни surrogate примарни клучеви → нема делумни зависности.
-- 3NF/BCNF: Сите атрибути кои не се клучеви зависат само од примарниот клуч. 
-Го дефинирав UNIQUE (tag_name) за да обезбедам интегритет.
-
-==== 6. TAG_ASSIGNED_TO_TRANSACTION
-
-TAG_ASSIGNED_TO_TRANSACTION(__**tag_assigned_to_transaction_id**__, **tag_id*** (TAG), **transaction_id*** (TRANSACTION))
-
-{{{tag_assigned_to_transaction_id → tag_id, transaction_id}}}
-
-- 1NF: Сите атрибути се атомски, нема повторувачки групи.
-- 2NF: Сите табели имаат едноатрибутни surrogate примарни клучеви → нема делумни зависности.
-- 3NF/BCNF: Сите атрибути кои не се клучеви зависат само од примарниот клуч. 
-И овде користам surrogate клуч за конзистентност, но истовремено додадов UNIQUE(tag_id, transaction_id) за да спречам дупликати.
-
-**Шемата е во 3NF (практично BCNF) за сите табели. Единствениот свесен исклучок е {{{net_amount}}} во {{{TRANSACTION}}}, кој е додаден заради перформанси.**
-
-https://develop.finki.ukim.mk/projects/FEIN/wiki/AdvancedDatabaseDevelopment
-= Напреден развој на датабаза (Тригери, погледи)
-
-=== Тригери:
-- [wiki:Trigger1 Автоматска пресметка на нето износ на трансакција]
-- [wiki:Trigger2 Автоматско ажурирање на баланс на трансакциска сметка]
-- [wiki:Trigger3 Контрола на баланс со дозволен лимит за тип на акаунт]
-- [wiki:Trigger5 Автоматско бришење на трансакции без помошни(breakdown) записи]
-- [wiki:Trigger6 Превенција на бришење на акаунти со историја]
-
-- [wiki:Trigger4 Автоматско поставување на датум на трансакција] //Тривијално-може да се постигне со поставување на `DEFAULT` вредност на атрибутот
-
-=== Погледи:
-- [wiki:View1 Целсен преглед на трансакции]
-- [wiki:View2 Преглед на трансакциски сметки со баланси]
-- [wiki:View3 Месечно потрошено по корисник]
-- [wiki:View4 Извештај по тагови]
-- [wiki:View5 Извештај за дневно трошење]
-
-https://develop.finki.ukim.mk/projects/FEIN/wiki/Trigger1
-= Автоматска пресметка на нето износ на трансакција
-
-==== Опис
-Овој тригер служи за автоматска пресметка и ажурирање на нето износот (`net_amount`) на една трансакција врз основа на сите поврзани записи во табелата `transaction_breakdown`
-
-Нето износот се дефинира како `net_amount = SUM(earned_amount - spent_amount)`
-
-Тригерот се активира секогаш кога ќе се:
-- додаде нов запис
-- ажурира постоечки запис
-- избрише запис
-од табелата `transaction_breakdown`
-
-Со ова се обезбедува конзистентност на податоците, без потреба апликацијата рачно да ја пресметува вредноста
-
-==== Табели опфатени со тригерот
-- transaction
-- transaction_breakdown
-
-==== Тип на тригер
-- AFTER INSERT
-- AFTER UPDATE
-- AFTER DELETE
-Активиран над табелата `transaction_breakdown`
-
-==== SQL Код
-{{{#!sql
-CREATE OR REPLACE FUNCTION update_transaction_net_amount()
-RETURNS TRIGGER
-LANGUAGE plpgsql
-AS $$
-BEGIN
-    UPDATE transaction t
-    SET net_amount = (
-        SELECT COALESCE(SUM(tb.earned_amount - tb.spent_amount), 0)
-        FROM transaction_breakdown tb
-        WHERE tb.transaction_id = t.transaction_id
-    )
-    WHERE t.transaction_id = COALESCE(NEW.transaction_id, OLD.transaction_id);
-
-    RETURN NULL;
-END;
-$$;
-
-
-CREATE TRIGGER trg_update_transaction_net_amount
-AFTER INSERT OR UPDATE OR DELETE
-ON transaction_breakdown
-FOR EACH ROW
-EXECUTE FUNCTION update_transaction_net_amount();
-}}}
-
-==== Објаснување на логиката
-
-При секоја промена во `transaction_breakdown`, тригерот:
-- Го пронаоѓа поврзаниот `transaction_id`
-- Ги сумира сите `earned_amount` и `spent_amount`
-- Ја ажурира колоната `net_amount` во табелата `transaction`
-
-Се користи `COALESCE` за да се избегне `NULL` вредност кога нема breakdown записи
-
-==== Причина за користење
-Овој тригер е неопходен бидејќи:
-- `net_amount` е деривиран атрибут
-- не смее да зависи од апликативна логика
-
-спречува:
-- неконзистентни податоци
-- човечки грешки
-- дуплирање на бизнис логика
-
-https://develop.finki.ukim.mk/projects/FEIN/wiki/Trigger2
-= Автоматско ажурирање на баланс на трансакциска сметка
-
-==== Опис
-Овој тригер овозможува автоматско ажурирање на балансот (`balance`) на трансакциските сметки при секоја промена во табелата `transaction_breakdown`
-
-Секој запис во `transaction_breakdown` претставува:
-- приход (`earned_amount`)
-- трошење (`spent_amount`)
-
-Тригерот гарантира дека балансот на сметката секогаш ја рефлектира реалната состојба по секоја трансакција
-
-==== Табели опфатени со тригерот
-- transaction_account
-- transaction_breakdown
-
-==== Тип на тригер
-- AFTER INSERT
-- AFTER UPDATE
-- AFTER DELETE
-Активиран над табелата `transaction_breakdown`
-
-==== SQL Код
-{{{#!sql
-CREATE OR REPLACE FUNCTION update_transaction_account_balance()
-RETURNS TRIGGER
-LANGUAGE plpgsql
-AS $$
-BEGIN
-    -- При бришење на запис (rollback на ефектот)
-    IF TG_OP = 'DELETE' THEN
-        UPDATE transaction_account
-        SET balance = balance - OLD.earned_amount + OLD.spent_amount
-        WHERE transaction_account_id = OLD.transaction_account_id;
-
-    -- При додавање на нов запис
-    ELSIF TG_OP = 'INSERT' THEN
-        UPDATE transaction_account
-        SET balance = balance + NEW.earned_amount - NEW.spent_amount
-        WHERE transaction_account_id = NEW.transaction_account_id;
-
-    -- При ажурирање на постоечки запис
-    ELSIF TG_OP = 'UPDATE' THEN
-        UPDATE transaction_account
-        SET balance = balance
-            - OLD.earned_amount + OLD.spent_amount
-            + NEW.earned_amount - NEW.spent_amount
-        WHERE transaction_account_id = NEW.transaction_account_id;
-    END IF;
-
-    RETURN NULL;
-END;
-$$;
-
-
-CREATE TRIGGER trg_update_transaction_account_balance
-AFTER INSERT OR UPDATE OR DELETE
-ON transaction_breakdown
-FOR EACH ROW
-EXECUTE FUNCTION update_transaction_account_balance();
-}}}
-
-==== Објаснување на логиката
-`DELETE`:
-- Се поништува ефектот од избришаниот запис
-
-`INSERT`:
-- Приходите се додаваат на балансот
-- Трошоците се одземаат од балансот
-
-`UPDATE`:
-- Прво се поништува стариот ефект
-- Потоа се применува новиот ефект
-
-
-==== Причина за користење
-Овој тригер е клучен затоа што:
-- `balance` е динамичка и критична вредност
-- рачно ажурирање е ризично
-- апликацијата не смее да биде единствен извор на вистина
-- базата ја контролира финансиската логика
-- се елиминираат грешки при ажурирање и бришење
-
-https://develop.finki.ukim.mk/projects/FEIN/wiki/Trigger3
-= Контрола на баланс со дозволен лимит за тип на акаунт
-
-==== Опис
-
-Овој тригер овозможува напредна валидација на трансакции со цел да се спречи надминување на дозволениот лимит на трансакциска сметка, при што:
-- се поддржува дозволен лимит и наместо фиксна граница (0), тригерот користи параметаризиран лимит, кој се дефинира при креирање на тригерот
-- се обработува edge case кога при `UPDATE` се менува трансакцискиот акаунт
-- се применува различна логика во зависност од типот на акаунт
-- типот на акаунтот не е експлицитен атрибут, туку се утврдува преку името на акаунтот, кое мора да го содржи типот (пример: Cash Wallet, Credit Card VISA, Forex EUR Account)
-
-Тригерот се извршува само ако името на акаунтот го содржи типот проследен како аргумент пред внесување или ажурирање на помошна трансакција
-
-Пример:
-- кеш сметки → лимит 0
-- кредитни сметки → лимит -10000
-
-==== Табели опфатени со тригерот
-- `transaction_account`
-- `transaction_breakdown`
-
-==== Тип на тригер
-- **BEFORE INSERT
-- **BEFORE UPDATE
-Активиран над табелата `transaction_breakdown`
-
-==== Поддржани типови на акаунти
-|| //Тип на акаунт || //Пример име || //Логика ||
-|| **CASH || Cash, Cash Wallet, Cash MKD || Не е дозволен негативен баланс ||
-|| **DEBIT || Debit Account || Не е дозволен негативен баланс ||
-|| **CREDIT || Credit Card VISA, Credit Card Gold || Дозволен негативен баланс до дефиниран лимит ||
-|| **FOREX || Forex EUR, FX USD || Дозволен негативен баланс до дефиниран лимит ||
-
-==== Параметри на тригерот
-|| //Параметар || //Опис ||
-|| **`TG_ARGV[0]` || Тип на акаунт (пример: cash, credit, debit, forex) ||
-|| **`TG_ARGV[1]` || Дозволен минимален баланс (лимит) ||
-
-==== SQL код
-{{{#!sql
-CREATE OR REPLACE FUNCTION enforce_account_limit_by_name()
-RETURNS TRIGGER
-LANGUAGE plpgsql
-AS $$
-DECLARE
-    allowed_type TEXT;
-    allowed_limit NUMERIC;
-
-    old_balance NUMERIC;
-    new_balance NUMERIC;
-
-    old_account_name TEXT;
-    new_account_name TEXT;
-BEGIN
-    allowed_type  := LOWER(TG_ARGV[0]);
-    allowed_limit := TG_ARGV[1]::NUMERIC;
-
-    -- ===============================
-    -- INSERT
-    -- ===============================
-    IF TG_OP = 'INSERT' THEN
-        SELECT account_name, balance
-        INTO new_account_name, new_balance
-        FROM transaction_account
-        WHERE transaction_account_id = NEW.transaction_account_id;
-
-        IF LOWER(new_account_name) LIKE '%' || allowed_type || '%' THEN
-            new_balance := new_balance
-                           + COALESCE(NEW.earned_amount, 0)
-                           - COALESCE(NEW.spent_amount, 0);
-
-            IF new_balance < allowed_limit THEN
-                RAISE EXCEPTION
-                    'The limit for the % account has been exceeded! Balance: %, limit: %',
-                    allowed_type, new_balance, allowed_limit;
-            END IF;
-        END IF;
-
-        RETURN NEW;
-    END IF;
-
-    -- ===============================
-    -- UPDATE
-    -- ===============================
-    IF TG_OP = 'UPDATE' THEN
-
-        -- Читање на стар и нов акаунт
-        SELECT account_name, balance
-        INTO old_account_name, old_balance
-        FROM transaction_account
-        WHERE transaction_account_id = OLD.transaction_account_id;
-
-        SELECT account_name, balance
-        INTO new_account_name, new_balance
-        FROM transaction_account
-        WHERE transaction_account_id = NEW.transaction_account_id;
-
-        -- Проверка на стариот акаунт (rollback ефект)
-        IF LOWER(old_account_name) LIKE '%' || allowed_type || '%' THEN
-            old_balance := old_balance
-                           - COALESCE(OLD.earned_amount, 0)
-                           + COALESCE(OLD.spent_amount, 0);
-
-            IF old_balance < allowed_limit THEN
-                RAISE EXCEPTION
-                    'The limit for the % account has been exceeded during rollback! Balance: %, limit: %',
-                    allowed_type, old_balance, allowed_limit;
-            END IF;
-        END IF;
-
-        -- Проверка на новиот акаунт (apply нов ефект)
-        IF LOWER(new_account_name) LIKE '%' || allowed_type || '%' THEN
-            new_balance := new_balance
-                           + COALESCE(NEW.earned_amount, 0)
-                           - COALESCE(NEW.spent_amount, 0);
-
-            IF new_balance < allowed_limit THEN
-                RAISE EXCEPTION
-                    'The limit for the % account has been exceeded during updation! Balance: %, limit: %',
-                    allowed_type, new_balance, allowed_limit;
-            END IF;
-        END IF;
-
-        RETURN NEW;
-    END IF;
-
-    RETURN NEW;
-END;
-$$;
-}}}
-
-===== Креирање на тригерите:
-
-- Кеш акаунти (без дозволен минус)
-{{{#!sql
-CREATE TRIGGER trg_cash_account_limit
-BEFORE INSERT OR UPDATE
-ON transaction_breakdown
-FOR EACH ROW
-EXECUTE FUNCTION enforce_account_limit_by_name('cash', 0);
-}}}
-
-- Кредитни акаунти (дозволен минус до -10000)
-{{{#!sql
-CREATE TRIGGER trg_credit_account_limit
-BEFORE INSERT OR UPDATE
-ON transaction_breakdown
-FOR EACH ROW
-EXECUTE FUNCTION enforce_account_limit_by_name('credit', -10000);
-}}}
-
-- Дебитни акаунти (без дозволен минус)
-{{{#!sql
-CREATE TRIGGER trg_debit_account_limit
-BEFORE INSERT OR UPDATE
-ON transaction_breakdown
-FOR EACH ROW
-EXECUTE FUNCTION enforce_account_limit_by_name('debit', 0);
-}}}
-
-- Forex акаунти (дозволен минус до -1000)
-{{{#!sql
-CREATE TRIGGER trg_forex_account_limit
-BEFORE INSERT OR UPDATE
-ON transaction_breakdown
-FOR EACH ROW
-EXECUTE FUNCTION enforce_account_limit_by_name('forex', -1000);
-}}}
-
-==== Објаснување на логиката
-
-Типот на акаунтот се утврдува со пребарување во `account_name`, се користи `LIKE %type%` за флексибилност
-
-При `UPDATE`:
-- се проверува стариот акаунт (rollback)
-- потоа новиот акаунт (apply)
-
-Лимитот се применува само ако типот се совпаѓа
-
-Дозволениот лимит се проследува како аргумент на тригерот, функцијата ја чита вредноста преку `TG_ARGV`, балансот се пресметува пред реалното запишување, доколку се надмине лимитот → трансакцијата се блокира
-
-Овој пристап овозможува флексибилна и повторно употреблива логика
-
-==== Причина за користење
-- Поддршка за различни типови сметки
-- Централизирана контрола на бизнис логика
-- Избегнување "hardcoded" вредности
-- Лесна промена без измена на кодот
-
-https://develop.finki.ukim.mk/projects/FEIN/wiki/Trigger5
-= Автоматско бришење на трансакции без помошни(breakdown) записи
-
-==== Опис
-Овој тригер автоматски ја брише трансакцијата доколку по бришење на запис од `transaction_breakdown` повеќе не постојат поврзани `breakdown` записи за таа трансакција
-
-Со ова се обезбедува дека не постојат „празни“ трансакции кои немаат финансиско значење
-
-==== Табели опфатени со тригерот
-- `transaction`
-- `transaction_breakdown`
-
-==== Тип на тригер
-- **AFTER DELETE
-Активиран над табелата `transaction_breakdown`
-
-==== SQL код
-{{{#!sql
-CREATE OR REPLACE FUNCTION delete_empty_transaction()
-RETURNS TRIGGER
-LANGUAGE plpgsql
-AS $$
-BEGIN
-    -- Проверка дали постојат други breakdown записи за истата трансакција
-    IF NOT EXISTS (
-        SELECT 1
-        FROM transaction_breakdown
-        WHERE transaction_id = OLD.transaction_id
-    ) THEN
-        DELETE FROM transaction
-        WHERE transaction_id = OLD.transaction_id;
-    END IF;
-
-    RETURN NULL;
-END;
-$$;
-
-
-CREATE TRIGGER trg_delete_empty_transaction
-AFTER DELETE
-ON transaction_breakdown
-FOR EACH ROW
-EXECUTE FUNCTION delete_empty_transaction();
-}}}
-
-==== Објаснување на логиката
-По бришење на breakdown запис:
-- се проверува дали трансакцијата има други поврзани записи
-Доколку нема:
-    - трансакцијата се смета за невалидна
-    - автоматски се брише
-Ова однесување ја моделира композицијата помеѓу `transaction` и `transaction_breakdown`
-
-==== Причина за користење
-
-Овој тригер е важен затоа што:
-- трансакција без breakdown нема значење
-- спречува акумулација на некорисни податоци
-- ја одржува базата чиста и логична
-Оваа логика не може секогаш безбедно да се остави на апликацијата
-
-https://develop.finki.ukim.mk/projects/FEIN/wiki/Trigger6
-= Превенција на бришење на акаунти со историја
-
-==== Опис
-Овој тригер спречува бришење на трансакциска сметка доколку таа има поврзани записи во табелата `transaction_breakdown`
-
-Со ова се заштитува финансиската историја и се спречува губење на важни податоци
-
-==== Табели опфатени со тригерот
-- `transaction_account`
-- `transaction_breakdown`
-
-==== Тип на тригер
-- **BEFORE DELETE
-Активиран над табелата `transaction_account`
-
-==== SQL код
-{{{#!sql
-CREATE OR REPLACE FUNCTION prevent_account_delete_with_history()
-RETURNS TRIGGER
-LANGUAGE plpgsql
-AS $$
-BEGIN
-    IF EXISTS (
-        SELECT 1
-        FROM transaction_breakdown
-        WHERE transaction_account_id = OLD.transaction_account_id
-    ) THEN
-        RAISE EXCEPTION
-            'The transaction account cannot be deleted because it contains history of transactions, delete them first!';
-    END IF;
-
-    RETURN OLD;
-END;
-$$;
-
-
-CREATE TRIGGER trg_prevent_account_delete_with_history
-BEFORE DELETE
-ON transaction_account
-FOR EACH ROW
-EXECUTE FUNCTION prevent_account_delete_with_history();
-}}}
-
-==== Објаснување на логиката
-Пред бришење на сметката:
-- се проверува дали постојат поврзани breakdown записи
-Доколку постојат:
-- бришењето се прекинува
-- се прикажува соодветна грешка
-Ова обезбедува зачувување на историските податоци
-
-==== Причина за користење
-Овој тригер е важен бидејќи:
-- финансиските податоци не смеат да се губат
-- бришење на сметка може да доведе до неконзистентни извештаи, сите извештаи остануваат точни и доверливи
-- `ON DELETE CASCADE` не е соодветен за ваков случај:
-`ON DELETE CASCADE` автоматски ги брише сите поврзани записи во зависните табели кога ќе се избрише родителскиот запис
-
-Иако ова е корисно во одредени сценарија, во финансиски системи како FEiN тоа претставува сериозен ризик за губење на финансиската историја, која никогаш не смее автоматски да се избрише
-
-Сметката можеби повеќе не е активна, но историјата останува, а `ON DELETE CASCADE` имплицира дека:
-- ако сметката не постои → трансакциите не постоеле, што е бизнис логички неточно
-
-https://develop.finki.ukim.mk/projects/FEIN/wiki/Trigger4
-= Автоматско поставување на датум на трансакција
-
-==== Опис
-Овој тригер автоматски го поставува датумот на трансакцијата (`date`) доколку при внесување на трансакција истиот не е зададен, базата на податоци ќе го користи тековниот системски датум и време
-
-Со ова се обезбедува дека секоја трансакција има валиден временски запис
-
-==== Табели опфатени со тригерот
-- `transaction`
-
-==== Тип на тригер
-- **BEFORE INSERT
-Активиран над табелата `transaction`
-
-==== SQL код
-{{{#!sql
-CREATE OR REPLACE FUNCTION set_transaction_date()
-RETURNS TRIGGER
-LANGUAGE plpgsql
-AS $$
-BEGIN
-    IF NEW.date IS NULL THEN
-        NEW.date := NOW();
-    END IF;
-
-    RETURN NEW;
-END;
-$$;
-
-
-CREATE TRIGGER trg_set_transaction_date
-BEFORE INSERT
-ON transaction
-FOR EACH ROW
-EXECUTE FUNCTION set_transaction_date();
-}}}
-
-==== Објаснување на логиката
-Пред внесување на нова трансакција:
-- се проверува дали колоната `date` е празна
-- доколку е `NULL`, се поставува тековниот датум и време
-
-==== Причина за користење
-Овој тригер е важен затоа што:
-- овозможува стандардизирано однесување при внесување податоци
-- времето е клучен атрибут за финансиски податоци
-- апликацијата не секогаш е сигурен извор
-- се елиминираат празни или невалидни датуми
-Исто така:
-- го поедноставува API-то
-- ја намалува комплексноста на frontend-логиката
-
-https://develop.finki.ukim.mk/projects/FEIN/wiki/View1
-= Целосен преглед на трансакции
-
-==== Опис
-Овој поглед обезбедува целосен преглед на трансакциите, комбинирајќи податоци од повеќе табели во еден приказ
-
-Погледот е наменет за:
-- прикажување трансакции во кориснички интерфејс
-- извештаи
-- аналитички пребарувања
-
-==== Табели опфатени со погледот
-- `transaction`
-- `transaction_breakdown`
-- `transaction_account`
-- `tag`
-- `tag_assigned_to_transaction`
-
-==== SQL код
-{{{#!sql
-CREATE OR REPLACE VIEW vw_transaction_overview AS
-SELECT
-    t.transaction_id,
-    t.transaction_name,
-    t.date,
-    t.net_amount,
-
-    ta.transaction_account_id,
-    ta.account_name,
-
-    STRING_AGG(DISTINCT tg.tag_name, ', ') AS tags
-
-FROM transaction t
-JOIN transaction_breakdown tb
-    ON tb.transaction_id = t.transaction_id
-JOIN transaction_account ta
-    ON ta.transaction_account_id = tb.transaction_account_id
-LEFT JOIN tag_assigned_to_transaction tat
-    ON tat.transaction_id = t.transaction_id
-LEFT JOIN tag tg
-    ON tg.tag_id = tat.tag_id
-
-GROUP BY
-    t.transaction_id,
-    t.transaction_name,
-    t.date,
-    t.net_amount,
-    ta.transaction_account_id,
-    ta.account_name;
-}}}
-
-==== Објаснување на логиката
-- Се комбинираат основните податоци за трансакцијата
-- Се прикажува сметката на која трансакцијата влијае
-- Таговите се агрегираат во една текстуална колона со `STRING_AGG`
-- `LEFT JOIN` се користи за тагови бидејќи тие се опционални
-
-==== Причина за користење
-Овој поглед е неопходен бидејќи:
-- ја централизира логиката за читање на трансакции
-- ја намалува комплексноста во апликацијата
-- обезбедува конзистентен формат за приказ на податоци
-Без ваков поглед, секој извештај би морал повторно да ги имплементира истите `JOIN`-ови
-https://develop.finki.ukim.mk/projects/FEIN/wiki/View2
-= Преглед на трансакциски сметки со баланси
-
-==== Опис
-Овој поглед обезбедува преглед на сите трансакциски сметки, заедно со нивниот тековен баланс и агрегирани финансиски податоци
-
-Погледот е наменет за:
-- dashboard приказ
-- брз преглед на финансиска состојба
-- извештаи по сметка
-
-==== Табели опфатени со погледот
-- `transaction_account`
-
-==== SQL код
-{{{#!sql
-CREATE OR REPLACE VIEW vw_transaction_account_summary AS
-SELECT
-    transaction_account_id,
-    account_name,
-    balance
-FROM transaction_account
-GROUP BY
-    transaction_account_id,
-    account_name,
-    balance;
-}}}
-
-==== Објаснување на логиката
-Брз и краток поглед кој ги зема полињата од `transaction_account`
-
-==== Причина за користење
-Овој поглед е важен затоа што:
-- balance покажува моментална состојба
-- сите релевантни податоци за сметка се достапни во една структура
-
-https://develop.finki.ukim.mk/projects/FEIN/wiki/View3
-= Месечно потрошено по корисник
-
-==== Опис
-Овој поглед обезбедува преглед на месечната потрошувачка за секој корисник, каде податоците се групираат по корисник и месец(придружен со година)
-
-Погледот е наменет за:
-- месечни финансиски извештаи
-- тренд анализи
-- визуелизации (графици)
-
-==== Табели опфатени со погледот
-- `transaction`
-- `transaction_breakdown`
-- `transaction_account`
-
-==== SQL код
-{{{#!sql
-CREATE OR REPLACE VIEW vw_monthly_spending_by_user AS
-SELECT
-    ta.user_id,
-    EXTRACT(MONTH FROM t.date) AS month,
-    EXTRACT(YEAR FROM t.date) AS year,    
-
-    COALESCE(SUM(tb.spent_amount), 0) AS total_spent
-
-FROM transaction t
-JOIN transaction_breakdown tb
-    ON tb.transaction_id = t.transaction_id
-JOIN transaction_account ta
-    ON ta.transaction_account_id = tb.transaction_account_id
-
-GROUP BY
-    ta.user_id,
-    EXTRACT(YEAR FROM t.date),
-    EXTRACT(MONTH FROM t.date);
-}}}
-
-==== Објаснување на логиката
-- Се користи датумот на трансакцијата како временска референца
-- Се агрегира само `spent_amount`
-- Податоците се групираат по корисник и по месец
-Ова овозможува јасен временски преглед на трошоците
-
-==== Причина за користење
-Овој поглед е важен затоа што:
-- месечните извештаи се честa потреба
-- сложените агрегации се централизирани
-- апликацијата не мора да имплементира временска логика
-
-https://develop.finki.ukim.mk/projects/FEIN/wiki/View4
-= Извештај по тагови
-
-==== Опис
-Овој поглед обезбедува преглед на потрошувачката по тагови, со што се овозможува категоризација и анализа на трошоците според нивната намена (храна, транспорт, сметки, забава и сл.)
-
-Погледот ги поврзува трансакциите со нивните тагови и ја сумира вкупната потрошувачка за секој таг
-
-==== Табели опфатени со погледот
-- `tag`
-- `tag_assigned_to_transaction`
-- `transaction`
-- `transaction_breakdown`
-
-==== SQL код
-{{{#!sql
-CREATE OR REPLACE VIEW vw_spending_by_tag AS
-SELECT
-    tg.tag_id,
-    tg.tag_name,
-
-    COALESCE(SUM(tb.spent_amount), 0) AS total_spent,
-    COUNT(DISTINCT t.transaction_id) AS transaction_count
-
-FROM tag tg
-JOIN tag_assigned_to_transaction tat
-    ON tat.tag_id = tg.tag_id
-JOIN transaction t
-    ON t.transaction_id = tat.transaction_id
-JOIN transaction_breakdown tb
-    ON tb.transaction_id = t.transaction_id
-
-GROUP BY
-    tg.tag_id,
-    tg.tag_name;
-}}}
-
-==== Објаснување на логиката
-- Се земаат само трансакции кои имаат доделен таг
-- `spent_amount` се агрегира по таг
-- `COUNT(DISTINCT ...)` се користи за да се избегне дуплирање на трансакции
-
-==== Причина за користење
-- категоризацијата е суштинска за лични финансии
-- овозможува анализа на трошоци по намена
-- ја елиминира потребата од сложени `JOIN` и `GROUP BY` барања
-
-https://develop.finki.ukim.mk/projects/FEIN/wiki/View5
-= Извештај за дневно трошење
-
-==== Опис
-Овој поглед обезбедува преглед на дневната потрошувачка за секој корисник, каде податоците се групираат по корисник и датум
-
-Погледот е наменет за:
-- дневни извештаи
-- анализа на навики
-- пресметка на просечна потрошувачка
-
-==== Табели опфатени со view-от
-- `transaction`
-- `transaction_breakdown`
-- `transaction_account`
-
-==== SQL код
-{{{#!sql
-CREATE OR REPLACE VIEW vw_daily_spending_by_user AS
-SELECT
-    ta.user_id,
-    DATE(t.date) AS spending_date,
-
-    COALESCE(SUM(tb.spent_amount), 0) AS total_spent
-
-FROM transaction t
-JOIN transaction_breakdown tb
-    ON tb.transaction_id = t.transaction_id
-JOIN transaction_account ta
-    ON ta.transaction_account_id = tb.transaction_account_id
-
-GROUP BY
-    ta.user_id,
-    DATE(t.date);
-}}}
-
-==== Објаснување на логиката
-- Се користи само датумскиот дел од `timestamp`
-- Се агрегира исклучиво `spent_amount`
-- Групирањето по корисник овозможува персонализирани анализи
-
-==== Причина за користење
-- дневната анализа дава најпрецизен увид во навиките
-- е основа за пресметка на просеци и лимити
-- ги поедноставува временските пресметки
-
-https://develop.finki.ukim.mk/projects/FEIN/wiki/AdvancedApplicationDevelopment
-= Напреден апликативен развој (Трансакции, Pooling)
-
-За да се разликуваат трансакциите во системот како ентитети и трансакциите како теоретски поим во областа на датабазите, првите ќе ги нарекувам "финансиски трансакции", а вторите "трансакции во датабаза" во понатамошните спомнувања
-
-=== Pooling на конекции
-Го користам пакетот `postgres.js` (инстанциран во [source:/app/lib/db.ts]), кој автоматски воспоставува pooling. Без потреба од дополнителни конфигурации, овој систем одржува активни конекции(стандардно 10 паралелни конекции) што драстично ја намалува латенцијата помеѓу серверските акции во Next.js и операциите на базата на податоци.
-
-Овој систем е заштитен од дуплирање на конекции при hot-reload во развојна околина, со зачувување на инстанцата во `global` објектот.
-
-=== Трансакции во датабаза
-Користам `sql.begin()` блокови за секоја операција што бара прво проверка или повеќекратен упис, елиминирајќи го ризикот од race conditions. 
-
-'''Пример 1: Внесување на финансиска трансакција''' 
-
-Сместена во [source:/app/(app)/add/actions.ts#L205:292], акцијата `addTransaction` ги извршува следните чекори како една атомична целина:
- 1. Впишува основен запис во табелата `transaction`.
- 2. Впишува релациони записи во `transaction_breakdown`.
- 3. Го ажурира балансот на соодветната сметка во `transaction_account`.
- 4. Селектира постојни или внесува нови тагови во `tag`.
- 5. Гради поврзување во табелата `tag_assigned_to_transaction`.
-
-'''Пример 2: Валидација и манипулација врз основа на проверка'''
-
-За да се осигураме дека нема да се зачуваат дупликати поради истовремени кликови или барања(race conditions), се користи истата `sql.begin()` логика при креирање и промена на профили:
-- '''Регистрација на профил:''' Во [source:/app/(auth)/actions.ts#L68:79] проверката дали внесениот `email` веќе постои се извршува во иста трансакција со креирањето на новиот профил. Доколку друга регистрација се обиде со истиот мејл во тој момент, трансакцијата се блокира.
-- '''Ажурирање на профил:''' При промена на email во [source:/app/(app)/profile/actions.ts#L35:51], сите проверки за преклопување и самата `UPDATE` команда се спроведуват атомично.
-- '''Додавање тагови:''' Во [source:app/(app)/add/actions.ts#L80:90], акцијата за додавање нов таг користи трансакција за првично да изврши `SELECT` и да провери дали постои таг со тоа име, пред да направи `INSERT`.
-
-'''Заштитен механизам:''' Ако било кој од чекорите во блоковите врати грешка(на пример: тагот веќе постои, балансот е невалиден или податоците се некомплетни), целиот `sql.begin` блок прави '''rollback''' и извршувањето се прекинува, што значи дека ниту еден делумен податок не останува трајно зачуван во базата.
-
-https://develop.finki.ukim.mk/projects/FEIN/wiki/OtherTopics
-= Останати теми: Безбедност, перформанси и одржување на базата
-
-=== 1. Безбедност на ниво на база
-Имплементирани се следните безбедносни механизми директно при комуникацијата со базата, со што се намалува нападниот вектор независно од самиот NextJS сервер:
- - '''Заштита од SQL вбризгување(SQL injection):''' Клиентската библиотека `postgres.js` користи параметризирани прашалници преку Tagged Template Literals(на пример `sql\`...\``) по дизајн. Ова спречува директно извршување на малициозен SQL код преку корисничките влезни параметри, бидејќи влезот е секогаш парсиран како податок, а не како команда.
- - '''Енкриптирана комуникација(SSL/TLS):''' Како што е наведено во конфигурацискиот фајл([source:/app/lib/db.ts#L11]), конекцијата кон базата строго наметнува SSL сертификат преку параметарот `ssl: 'require'`. Ова ја штити транзицијата на чувствителните податоци и лозинки од "man-in-the-middle" напади.
- - '''Безбедно поврзување со база:''' Конекциските стрингови никогаш не се чуваат во изворниот код. Тие се изолирани преку заштитени околински променливи (`POSTGRES_URL`).
-
-=== 2. Перформанси и оптимизација
-Анализата е направена со `EXPLAIN (ANALYZE, BUFFERS)`, при што се споредува планот за извршување пред и по додавање на дополнителните индекси. Во почетната состојба базата ги содржи само индексите кои PostgreSQL автоматски ги креира за `PRIMARY KEY` и `UNIQUE` ограничувања. Овие индекси не се третираат како дополнителна оптимизација, туку како дел од основната релациска структура на базата.
-
-Првично тестирање беше направено со мал број редови во табелите:
-
-[wiki:SmallDatabasePerformance Перформанси со мала датабаза]
-
-Во оваа состојба PostgreSQL користеше `Seq Scan` и по додавање на индексите. Тоа е очекувано однесување, бидејќи за табели со многу мал број редови целосното читање на табелата е поевтино од пристап преку индекс.
-
-Поради тоа, за пореална анализа беше генериран поголем сет на тест-податоци, при што податоците се логички поврзани преку постоечките релации во базата.
-
-По генерирање на тест-податоците, базата ја има следната состојба:
-
-||= Табела =||= Број на редови =||
-|| `user` || 103 ||
-|| `transaction` || 100012 ||
-|| `transaction_account` || 505 ||
-|| `transaction_breakdown` || 110011 ||
-|| `tag` || 31 ||
-|| `tag_assigned_to_transaction` || 125016 ||
-
-Оваа количина на податоци е доволна за PostgreSQL optimizer-от да започне да прави различни планови за извршување и да користи дел од предложените индекси кога тие се поисплатливи од `Seq Scan`.
-
-==== Предложени дополнителни индекси ====
-
-Следните индекси се предложени затоа што колоните често се користат во `JOIN` и `WHERE` услови во аналитичките извештаи:
-
-{{{#!sql
-CREATE INDEX IF NOT EXISTS idx_transaction_date
-ON "transaction"(date);
-
-CREATE INDEX IF NOT EXISTS idx_transaction_account_user_id
-ON transaction_account(user_id);
-
-CREATE INDEX IF NOT EXISTS idx_transaction_breakdown_transaction_id
-ON transaction_breakdown(transaction_id);
-
-CREATE INDEX IF NOT EXISTS idx_transaction_breakdown_account_id
-ON transaction_breakdown(transaction_account_id);
-
-CREATE INDEX IF NOT EXISTS idx_tag_assigned_transaction_id
-ON tag_assigned_to_transaction(transaction_id);
-
-CREATE INDEX IF NOT EXISTS idx_tag_assigned_tag_id
-ON tag_assigned_to_transaction(tag_id);
-}}}
-
-Индексот `idx_transaction_date` е наменет за извештаи кои филтрираат трансакции според временски период. Индексот `idx_transaction_account_user_id` е наменет за извештаи кои ги ограничуваат резултатите на конкретен корисник. Индексите на `transaction_breakdown` и `tag_assigned_to_transaction` се наменети за побрзо поврзување на трансакциите со сметки и тагови.
-
-==== Сценарио 1: Трендови на трошење според тагови за корисник ====
-
-'''Цел:'''
-Овој извештај ја прикажува потрошувачката по тагови за конкретен корисник во последните шест месеци. Прашалникот е комплексен затоа што ги поврзува табелите `tag_assigned_to_transaction`, `transaction`, `transaction_breakdown`, `transaction_account` и `tag`, а дополнително врши филтрирање по корисник и датум, групирање по таг и месец, и сумирање на потрошената сума.
-
-'''Анализиран SQL:'''
-
-{{{#!sql
-EXPLAIN (ANALYZE, BUFFERS)
-SELECT
-tg.tag_name,
-DATE_TRUNC('month', t.date) AS month,
-SUM(tb.spent_amount) AS total_spent
-FROM tag_assigned_to_transaction tat
-JOIN "transaction" t
-ON tat.transaction_id = t.transaction_id
-JOIN transaction_breakdown tb
-ON t.transaction_id = tb.transaction_id
-JOIN transaction_account ta
-ON tb.transaction_account_id = ta.transaction_account_id
-JOIN tag tg
-ON tat.tag_id = tg.tag_id
-WHERE
-ta.user_id = 5
-AND t.date >= DATE_TRUNC('month', CURRENT_DATE - INTERVAL '6 MONTHS')
-GROUP BY
-tg.tag_name, month
-ORDER BY
-tg.tag_name, month;
-}}}
-
-'''Релевантни индекси за ова сценарио:'''
-
-{{{#!sql
-CREATE INDEX IF NOT EXISTS idx_transaction_date
-ON "transaction"(date);
-
-CREATE INDEX IF NOT EXISTS idx_transaction_account_user_id
-ON transaction_account(user_id);
-
-CREATE INDEX IF NOT EXISTS idx_transaction_breakdown_transaction_id
-ON transaction_breakdown(transaction_id);
-
-CREATE INDEX IF NOT EXISTS idx_transaction_breakdown_account_id
-ON transaction_breakdown(transaction_account_id);
-
-CREATE INDEX IF NOT EXISTS idx_tag_assigned_transaction_id
-ON tag_assigned_to_transaction(transaction_id);
-
-CREATE INDEX IF NOT EXISTS idx_tag_assigned_tag_id
-ON tag_assigned_to_transaction(tag_id);
-}}}
-
-'''Пред додавање на индексите:'''
-
-{{{#!sql
-GroupAggregate  (cost=4898.54..4900.99 rows=98 width=52) (actual time=51.593..51.622 rows=7 loops=1)
-Group Key: tg.tag_name, (date_trunc('month'::text, t.date))
-Buffers: shared hit=4383
-->  Sort  (cost=4898.54..4898.79 rows=98 width=26) (actual time=51.574..51.582 rows=81 loops=1)
-Sort Key: tg.tag_name, (date_trunc('month'::text, t.date))
-Sort Method: quicksort  Memory: 28kB
-->  Hash Join  (cost=2498.81..4895.30 rows=98 width=26) (actual time=28.248..51.522 rows=81 loops=1)
-Hash Cond: (tat.tag_id = tg.tag_id)
-->  Hash Join  (cost=2497.11..4893.07 rows=98 width=18) (actual time=28.204..51.436 rows=81 loops=1)
-Hash Cond: (tat.transaction_id = t.transaction_id)
-->  Seq Scan on tag_assigned_to_transaction tat
-(actual time=0.014..9.236 rows=125016 loops=1)
-->  Hash
-->  Nested Loop
-->  Hash Join
-Hash Cond: (tb.transaction_account_id = ta.transaction_account_id)
-->  Seq Scan on transaction_breakdown tb
-(actual time=0.012..8.429 rows=110011 loops=1)
-->  Hash
-->  Seq Scan on transaction_account ta
-Filter: (user_id = 5)
-Rows Removed by Filter: 500
-->  Index Scan using transaction_pkey on transaction t
-Index Cond: (transaction_id = tb.transaction_id)
-Filter: (date >= date_trunc('month'::text, (CURRENT_DATE - '6 mons'::interval)))
-->  Hash
-->  Seq Scan on tag tg
-Planning Time: 1.392 ms
-Execution Time: 51.744 ms
-}}}
-
-'''По додавање на индексите:'''
-
-{{{#!sql
-GroupAggregate  (cost=2490.16..2492.59 rows=97 width=52) (actual time=6.936..6.963 rows=7 loops=1)
-Group Key: tg.tag_name, (date_trunc('month'::text, t.date))
-Buffers: shared hit=3273 read=76
-->  Sort  (cost=2490.16..2490.40 rows=97 width=26) (actual time=6.921..6.928 rows=81 loops=1)
-Sort Key: tg.tag_name, (date_trunc('month'::text, t.date))
-Sort Method: quicksort  Memory: 28kB
-->  Nested Loop  (cost=16.95..2486.96 rows=97 width=26) (actual time=0.155..6.835 rows=81 loops=1)
-->  Nested Loop  (cost=16.80..2480.67 rows=97 width=18) (actual time=0.135..6.624 rows=81 loops=1)
-->  Nested Loop  (cost=16.38..2443.39 rows=77 width=22) (actual time=0.104..5.104 rows=81 loops=1)
-->  Nested Loop  (cost=16.09..2050.95 rows=1089 width=10) (actual time=0.087..0.458 rows=1000 loops=1)
-->  Bitmap Heap Scan on transaction_account ta
-Recheck Cond: (user_id = 5)
-->  Bitmap Index Scan on idx_transaction_account_user_id
-Index Cond: (user_id = 5)
-->  Bitmap Heap Scan on transaction_breakdown tb
-Recheck Cond: (transaction_account_id = ta.transaction_account_id)
-->  Bitmap Index Scan on idx_transaction_breakdown_account_id
-Index Cond: (transaction_account_id = ta.transaction_account_id)
-->  Index Scan using transaction_pkey on transaction t
-Index Cond: (transaction_id = tb.transaction_id)
-Filter: (date >= date_trunc('month'::text, (CURRENT_DATE - '6 mons'::interval)))
-->  Index Scan using idx_tag_assigned_transaction_id on tag_assigned_to_transaction tat
-Index Cond: (transaction_id = tb.transaction_id)
-->  Memoize
-Cache Key: tat.tag_id
-->  Index Scan using tag_pkey on tag tg
-Index Cond: (tag_id = tat.tag_id)
-Planning Time: 1.738 ms
-Execution Time: 7.055 ms
-}}}
-
-'''Споредба:'''
-
-||= Метрика =||= Пред индекси =||= По индекси =||
-|| Planning Time || 1.392 ms || 1.738 ms ||
-|| Execution Time || 51.744 ms || 7.055 ms ||
-|| Подобрување на Execution Time || / || ~86.36% ||
-|| Забрзување || / || ~7.33x ||
-|| Тип на скенирање пред индекси || `Seq Scan` на големите табели || / ||
-|| Тип на скенирање по индекси || / || `Bitmap Index Scan`, `Bitmap Heap Scan`, `Index Scan` ||
-|| Дали новите индекси се користат || Не || Да ||
-
-По додавање на индексите, PostgreSQL експлицитно ги користи:
-
-* `idx_transaction_account_user_id` преку `Bitmap Index Scan`
-* `idx_transaction_breakdown_account_id` преку `Bitmap Index Scan`
-* `idx_tag_assigned_transaction_id` преку `Index Scan`
-
-Индексот `idx_transaction_date` не е директно искористен во овој план, затоа што optimizer-от прво ги ограничува податоците преку корисничките сметки и breakdown записите, а потоа пристапува до трансакциите преку примарниот клуч `transaction_pkey`.
-
-'''Заклучок:'''
-
-Ова сценарио има најголемо подобрување. Времето на извршување се намали од 51.744 ms на 7.055 ms, што е приближно 86.36% подобрување, односно околу 7.33 пати побрзо извршување. Причината е што по додавање на индексите PostgreSQL повеќе не мора секвенцијално да ги чита `transaction_account`, `transaction_breakdown` и `tag_assigned_to_transaction`, туку директно ги лоцира релевантните записи преку индексите. Ова покажува дека индексите се корисни за извештаи кои филтрираат податоци за конкретен корисник и потоа ги поврзуваат трансакциите со тагови.
-
-==== Сценарио 2: Сметки со највисоко вкупно трошење во изминатата година ====
-
-'''Цел:'''
-Овој извештај ги прикажува сметките со најголема вкупна потрошувачка во последната година. Прашалникот користи `JOIN` помеѓу `transaction_breakdown`, `transaction` и `transaction_account`, филтрирање по датум, `SUM` агрегација, `GROUP BY`, `ORDER BY` и `LIMIT`.
-
-'''Анализиран SQL:'''
-
-{{{#!sql
-EXPLAIN (ANALYZE, BUFFERS)
-SELECT
-ta.account_name,
-COALESCE(SUM(tb.spent_amount), 0) AS total_spent
-FROM transaction_breakdown tb
-JOIN "transaction" t
-ON tb.transaction_id = t.transaction_id
-JOIN transaction_account ta
-ON tb.transaction_account_id = ta.transaction_account_id
-WHERE
-t.date >= NOW() - INTERVAL '1 YEAR'
-GROUP BY
-ta.account_name
-ORDER BY
-total_spent DESC
-LIMIT 10;
-}}}
-
-'''Релевантни индекси за ова сценарио:'''
-
-{{{#!sql
-CREATE INDEX IF NOT EXISTS idx_transaction_date
-ON "transaction"(date);
-
-CREATE INDEX IF NOT EXISTS idx_transaction_breakdown_transaction_id
-ON transaction_breakdown(transaction_id);
-
-CREATE INDEX IF NOT EXISTS idx_transaction_breakdown_account_id
-ON transaction_breakdown(transaction_account_id);
-}}}
-
-'''Пред додавање на индексите:'''
-
-{{{#!sql
-Limit  (cost=4969.28..4969.31 rows=10 width=49) (actual time=73.712..73.717 rows=10 loops=1)
-Buffers: shared hit=1540
-->  Sort  (cost=4969.28..4970.54 rows=505 width=49) (actual time=73.711..73.714 rows=10 loops=1)
-Sort Key: (COALESCE(sum(tb.spent_amount), '0'::numeric)) DESC
-Sort Method: top-N heapsort  Memory: 26kB
-->  HashAggregate  (cost=4952.06..4958.37 rows=505 width=49) (actual time=73.611..73.658 rows=113 loops=1)
-Group Key: ta.account_name
-->  Hash Join  (cost=2756.94..4883.26 rows=13760 width=23) (actual time=34.771..69.224 rows=13568 loops=1)
-Hash Cond: (tb.transaction_account_id = ta.transaction_account_id)
-->  Hash Join  (cost=2740.57..4830.47 rows=13760 width=10) (actual time=34.564..66.184 rows=13568 loops=1)
-Hash Cond: (tb.transaction_id = t.transaction_id)
-->  Seq Scan on transaction_breakdown tb
-(actual time=0.012..8.780 rows=110011 loops=1)
-->  Hash
-->  Seq Scan on transaction t
-Filter: (date >= (now() - '1 year'::interval))
-Rows Removed by Filter: 87672
-->  Hash
-->  Seq Scan on transaction_account ta
-Planning Time: 0.538 ms
-Execution Time: 73.792 ms
-}}}
-
-'''По додавање на индексите:'''
-
-{{{#!sql
-Limit  (cost=3675.91..3675.93 rows=10 width=49) (actual time=47.820..47.826 rows=10 loops=1)
-Buffers: shared hit=1540 read=36
-->  Sort  (cost=3675.91..3677.17 rows=505 width=49) (actual time=47.818..47.822 rows=10 loops=1)
-Sort Key: (COALESCE(sum(tb.spent_amount), '0'::numeric)) DESC
-Sort Method: top-N heapsort  Memory: 26kB
-->  HashAggregate  (cost=3658.68..3665.00 rows=505 width=49) (actual time=47.717..47.765 rows=113 loops=1)
-Group Key: ta.account_name
-->  Hash Join  (cost=1463.44..3589.80 rows=13776 width=23) (actual time=9.049..43.449 rows=13568 loops=1)
-Hash Cond: (tb.transaction_account_id = ta.transaction_account_id)
-->  Hash Join  (cost=1447.08..3536.98 rows=13776 width=10) (actual time=8.845..40.394 rows=13568 loops=1)
-Hash Cond: (tb.transaction_id = t.transaction_id)
-->  Seq Scan on transaction_breakdown tb
-(actual time=0.011..9.164 rows=110011 loops=1)
-->  Hash
-->  Bitmap Heap Scan on transaction t
-Recheck Cond: (date >= (now() - '1 year'::interval))
-Heap Blocks: exact=834
-->  Bitmap Index Scan on idx_transaction_date
-Index Cond: (date >= (now() - '1 year'::interval))
-->  Hash
-->  Seq Scan on transaction_account ta
-Planning Time: 0.654 ms
-Execution Time: 47.902 ms
-}}}
-
-'''Споредба:'''
-
-||= Метрика =||= Пред индекси =||= По индекси =||
-|| Planning Time || 0.538 ms || 0.654 ms ||
-|| Execution Time || 73.792 ms || 47.902 ms ||
-|| Подобрување на Execution Time || / || ~35.08% ||
-|| Забрзување || / || ~1.54x ||
-|| Тип на скенирање пред индекси || `Seq Scan` на `transaction` || / ||
-|| Тип на скенирање по индекси || / || `Bitmap Index Scan` + `Bitmap Heap Scan` на `transaction` ||
-|| Дали новите индекси се користат || Не || Да ||
-
-По додавање на индексите, PostgreSQL го користи `idx_transaction_date` преку:
-
-{{{#!sql
-Bitmap Index Scan on idx_transaction_date
-Index Cond: (date >= (now() - '1 year'::interval))
-}}}
-
-Останатите табели, како `transaction_breakdown` и `transaction_account`, сè уште се читаат со `Seq Scan`, затоа што query-то пресметува агрегат за сите сметки, а не за еден конкретен корисник или една конкретна сметка.
-
-'''Заклучок:'''
-
-Времето на извршување се намали од 73.792 ms на 47.902 ms, што претставува приближно 35.08% подобрување. Ова подобрување е директно поврзано со индексот `idx_transaction_date`, бидејќи извештајот филтрира трансакции само од последната година. Пред индексот, PostgreSQL мораше да ја чита целата табела `transaction` и да отфрли 87672 редови преку filter. По додавање на индексот, PostgreSQL користи `Bitmap Index Scan` за побрзо да ги најде трансакциите од последната година.
-
-==== Сценарио 3: Годишни трендови на трансакции за корисник ====
-
-'''Цел:'''
-Овој извештај ја прикажува потрошувачката по квартали за конкретен корисник во тековната година. Прашалникот користи `JOIN` помеѓу `transaction_breakdown`, `transaction` и `transaction_account`, филтрирање по корисник и временски период, `SUM` агрегации со `CASE WHEN`, `GROUP BY` и `ORDER BY`.
-
-Во анализата условот за година е напишан како временски опсег, наместо само со `EXTRACT(YEAR FROM t.date)`, за B-Tree индексот на `date` да може поефикасно да се користи кога табелата ќе порасне.
-
-'''Анализиран SQL:'''
-
-{{{#!sql
-EXPLAIN (ANALYZE, BUFFERS)
-SELECT
-ta.account_name,
-SUM(CASE WHEN EXTRACT(QUARTER FROM t.date) = 1 THEN tb.spent_amount ELSE 0 END) AS q1_spent,
-SUM(CASE WHEN EXTRACT(QUARTER FROM t.date) = 2 THEN tb.spent_amount ELSE 0 END) AS q2_spent,
-SUM(CASE WHEN EXTRACT(QUARTER FROM t.date) = 3 THEN tb.spent_amount ELSE 0 END) AS q3_spent,
-SUM(CASE WHEN EXTRACT(QUARTER FROM t.date) = 4 THEN tb.spent_amount ELSE 0 END) AS q4_spent
-FROM transaction_breakdown tb
-JOIN "transaction" t
-ON tb.transaction_id = t.transaction_id
-JOIN transaction_account ta
-ON tb.transaction_account_id = ta.transaction_account_id
-WHERE
-ta.user_id = 5
-AND t.date >= DATE_TRUNC('year', CURRENT_DATE)
-AND t.date < DATE_TRUNC('year', CURRENT_DATE) + INTERVAL '1 YEAR'
-GROUP BY
-ta.account_name
-ORDER BY
-ta.account_name;
-}}}
-
-'''Релевантни индекси за ова сценарио:'''
-
-{{{#!sql
-CREATE INDEX IF NOT EXISTS idx_transaction_date
-ON "transaction"(date);
-
-CREATE INDEX IF NOT EXISTS idx_transaction_account_user_id
-ON transaction_account(user_id);
-
-CREATE INDEX IF NOT EXISTS idx_transaction_breakdown_transaction_id
-ON transaction_breakdown(transaction_id);
-
-CREATE INDEX IF NOT EXISTS idx_transaction_breakdown_account_id
-ON transaction_breakdown(transaction_account_id);
-}}}
-
-'''Пред додавање на индексите:'''
-
-{{{#!sql
-GroupAggregate  (cost=2511.77..2514.21 rows=5 width=145) (actual time=26.493..26.495 rows=1 loops=1)
-Group Key: ta.account_name
-Buffers: shared hit=3706
-->  Sort  (cost=2511.77..2511.93 rows=67 width=31) (actual time=26.366..26.372 rows=70 loops=1)
-Sort Key: ta.account_name
-Sort Method: quicksort  Memory: 28kB
-->  Nested Loop  (cost=11.67..2509.73 rows=67 width=31) (actual time=0.322..26.308 rows=70 loops=1)
-->  Hash Join  (cost=11.38..2103.69 rows=1089 width=27) (actual time=0.301..21.727 rows=1000 loops=1)
-Hash Cond: (tb.transaction_account_id = ta.transaction_account_id)
-->  Seq Scan on transaction_breakdown tb
-(actual time=0.019..8.175 rows=110011 loops=1)
-->  Hash
-->  Seq Scan on transaction_account ta
-Filter: (user_id = 5)
-Rows Removed by Filter: 500
-->  Index Scan using transaction_pkey on transaction t
-Index Cond: (transaction_id = tb.transaction_id)
-Filter: ((date >= date_trunc('year'::text, (CURRENT_DATE)::timestamp with time zone))
-AND (date < (date_trunc('year'::text, (CURRENT_DATE)::timestamp with time zone) + '1 year'::interval)))
-Rows Removed by Filter: 1
-Planning Time: 0.495 ms
-Execution Time: 26.571 ms
-}}}
-
-'''По додавање на индексите:'''
-
-{{{#!sql
-GroupAggregate  (cost=2458.99..2461.40 rows=5 width=145) (actual time=5.086..5.088 rows=1 loops=1)
-Group Key: ta.account_name
-Buffers: shared hit=3023
-->  Sort  (cost=2458.99..2459.16 rows=66 width=31) (actual time=4.954..4.960 rows=70 loops=1)
-Sort Key: ta.account_name
-Sort Method: quicksort  Memory: 28kB
-->  Nested Loop  (cost=16.38..2457.00 rows=66 width=31) (actual time=0.104..4.917 rows=70 loops=1)
-->  Nested Loop  (cost=16.09..2050.95 rows=1089 width=27) (actual time=0.086..0.420 rows=1000 loops=1)
-->  Bitmap Heap Scan on transaction_account ta
-Recheck Cond: (user_id = 5)
-->  Bitmap Index Scan on idx_transaction_account_user_id
-Index Cond: (user_id = 5)
-->  Bitmap Heap Scan on transaction_breakdown tb
-Recheck Cond: (transaction_account_id = ta.transaction_account_id)
-->  Bitmap Index Scan on idx_transaction_breakdown_account_id
-Index Cond: (transaction_account_id = ta.transaction_account_id)
-->  Index Scan using transaction_pkey on transaction t
-Index Cond: (transaction_id = tb.transaction_id)
-Filter: ((date >= date_trunc('year'::text, (CURRENT_DATE)::timestamp with time zone))
-AND (date < (date_trunc('year'::text, (CURRENT_DATE)::timestamp with time zone) + '1 year'::interval)))
-Rows Removed by Filter: 1
-Planning Time: 0.724 ms
-Execution Time: 5.169 ms
-}}}
-
-'''Споредба:'''
-
-||= Метрика =||= Пред индекси =||= По индекси =||
-|| Planning Time || 0.495 ms || 0.724 ms ||
-|| Execution Time || 26.571 ms || 5.169 ms ||
-|| Подобрување на Execution Time || / || ~80.55% ||
-|| Забрзување || / || ~5.14x ||
-|| Тип на скенирање пред индекси || `Seq Scan` на `transaction_breakdown` и `transaction_account` || / ||
-|| Тип на скенирање по индекси || / || `Bitmap Index Scan`, `Bitmap Heap Scan`, `Index Scan` ||
-|| Дали новите индекси се користат || Не || Да ||
-
-По додавање на индексите, PostgreSQL ги користи:
-
-* `idx_transaction_account_user_id` преку `Bitmap Index Scan`
-* `idx_transaction_breakdown_account_id` преку `Bitmap Index Scan`
-
-Индексот `idx_transaction_date` не е директно искористен во овој план. Причината е што optimizer-от прво ги ограничува податоците преку конкретниот `user_id` и неговите сметки, а потоа пристапува до трансакциите преку `transaction_pkey`. Во овој случај филтрирањето по корисник е поселективно од самото филтрирање по датум.
-
-'''Заклучок:'''
-
-Времето на извршување се намали од 26.571 ms на 5.169 ms, што претставува приближно 80.55% подобрување, односно околу 5.14 пати побрзо извршување. Ова подобрување е резултат на тоа што PostgreSQL повеќе не ја чита целата табела `transaction_breakdown` и целата табела `transaction_account`, туку директно ги наоѓа сметките на конкретниот корисник и breakdown записите поврзани со тие сметки.
-
-==== Финален заклучок од анализата ====
-
-Првичната анализа со мал број податоци не покажа реална употреба на индексите, затоа што табелите беа премногу мали и PostgreSQL правилно избра `Seq Scan`. Поради тоа беше генериран поголем, логички поврзан сет на тест-податоци, кој содржи 100012 трансакции, 110011 breakdown записи и 125016 записи за доделени тагови.
-
-По зголемување на количината на податоци, индексите почнаа да имаат значајно влијание врз execution plan-от и времето на извршување.
-
-||= Сценарио =||= Execution Time пред индекси =||= Execution Time по индекси =||= Подобрување =||= Индекси искористени =||
-|| Трендови на трошење според тагови за корисник || 51.744 ms || 7.055 ms || ~86.36% || Да ||
-|| Сметки со највисоко трошење во изминатата година || 73.792 ms || 47.902 ms || ~35.08% || Да ||
-|| Годишни трендови на трансакции за корисник || 26.571 ms || 5.169 ms || ~80.55% || Да ||
-
-Најголемо подобрување се забележува кај извештаите кои филтрираат по конкретен корисник, затоа што индексите `idx_transaction_account_user_id` и `idx_transaction_breakdown_account_id` овозможуваат PostgreSQL директно да ги најде релевантните сметки и breakdown записи, наместо да ги чита целите табели.
-
-Кај извештајот за сметки со највисоко трошење во последната година, најважен е индексот `idx_transaction_date`, затоа што условот по датум ја намалува количината на трансакции кои треба да се обработат.
-
-Заклучокот е дека предложените индекси се оправдани и корисни за FEiN, особено кога базата содржи поголем број трансакции. Тие значително го намалуваат времето на извршување на аналитичките извештаи и ја подобруваат скалабилноста на системот.
-
-
-
-
-
-=== 3. Интегритет и конзистентност
-Со цел самата база да биде отпорна на грешки, имплементирани се стриктни ограничувања (`CONSTRAINTS`) и тригери кои ја заштитуваат финансиската историја на корисниците.
-
-{{{#!sql
-ALTER TABLE "user"
-ADD CONSTRAINT user_email_unique UNIQUE (email);
-}}}
-
-- '''Надворешни клучеви:''' Релациите помеѓу табелите се реализирани преку надворешни клучеви (`FOREIGN KEY`). Со тоа се спречува внесување на записи кои референцираат непостоечки корисници, сметки, трансакции или тагови.
-- '''Заштита на финансиска историја:''' За разлика од автоматско каскадно бришење, кај трансакциските сметки се користи тригер кој спречува бришење на сметка доколку таа има поврзани записи во `transaction_breakdown`. Ова е важно затоа што бришење на сметка со постоечка историја може да доведе до губење на финансиски податоци и неточни извештаи.
-- '''Одржување на логичка конзистентност:''' Дополнително, системот користи тригер кој автоматски ја брише трансакцијата доколку по бришење на breakdown запис повеќе не постојат поврзани breakdown записи за таа трансакција. Со тоа се спречува постоење на „празни“ трансакции без финансиско значење.
-- '''Ограничувања на уникатност:''' Корисничките мејлови се заштитени со `UNIQUE` ограничување, со што се гарантира дека во системот не можат да постојат два кориснички профили со иста емаил адреса.
-- '''Типови на податоци за финансиски вредности:''' Сите монетарни вредности во системот се складираат со типот `NUMERIC(10,2)`. Овој пристап обезбедува фиксна прецизност до две децимали и ги елиминира грешките кои можат да настанат при користење на типови со подвижна запирка(`FLOAT` или `DOUBLE PRECISION`) во финансиски пресметки.
-
-=== 4. Одржување на базата
-- '''Зачувување на структурните промени:''' Секоја промена на структурата на базата се документира во Trac документацијата и се имплементира директно во PostgreSQL преку SQL наредби. На тој начин документацијата останува усогласена со реалната имплементација на системот.
-- '''Чување на SQL скриптите:''' Секоја SQL скрипта извршена после првата DDL скрипта ја чувам хронолошки, за во случај да треба базата да се иницијализира повторно, да ги извршам редоследно и да ја добијам истата состојба која сум ја имал.
-- '''Одговорност за инфраструктурата:''' FEiN е дизајниран да работи врз PostgreSQL сервер обезбеден од надворешна инфраструктура. Конфигурацијата на серверот, резервните копии и механизмите за обновување на податоците се надвор од опсегот на самата апликација и зависат од околината во која е поставена базата на податоци.
-https://develop.finki.ukim.mk/projects/FEIN/wiki/SmallDatabasePerformance
-Анализата е направена со `EXPLAIN (ANALYZE, BUFFERS)`, при што се споредува планот за извршување пред и по додавање на дополнителните индекси. Во почетната состојба базата ги содржи само индексите кои PostgreSQL автоматски ги креира за `PRIMARY KEY` и `UNIQUE` ограничувања. Овие индекси не се третираат како дополнителна оптимизација, туку како дел од основната релациска структура на базата.
-
-Во моментот на тестирањето, базата ја има следната количина на податоци:
-
-||= Табела =||= Број на редови =||
-|| `user` || 3 ||
-|| `transaction` || 12 ||
-|| `transaction_account` || 5 ||
-|| `transaction_breakdown` || 11 ||
-|| `tag` || 11 ||
-|| `tag_assigned_to_transaction` || 16 ||
-
-Поради малата количина на податоци, очекувано е PostgreSQL во повеќе случаи да избере `Seq Scan`, бидејќи целосното читање на мала табела е поевтино од пристап преку индекс. Затоа, во анализата не се гледа само времето на извршување, туку и дали индексот навистина се користи во execution plan-от.
-
-==== Предложени дополнителни индекси ====
-
-Следните индекси се предложени затоа што колоните често се користат во `JOIN` и `WHERE` услови во аналитичките извештаи:
-
-{{{#!sql
-CREATE INDEX IF NOT EXISTS idx_transaction_date
-ON "transaction"(date);
-
-CREATE INDEX IF NOT EXISTS idx_transaction_account_user_id
-ON transaction_account(user_id);
-
-CREATE INDEX IF NOT EXISTS idx_transaction_breakdown_transaction_id
-ON transaction_breakdown(transaction_id);
-
-CREATE INDEX IF NOT EXISTS idx_transaction_breakdown_account_id
-ON transaction_breakdown(transaction_account_id);
-
-CREATE INDEX IF NOT EXISTS idx_tag_assigned_transaction_id
-ON tag_assigned_to_transaction(transaction_id);
-
-CREATE INDEX IF NOT EXISTS idx_tag_assigned_tag_id
-ON tag_assigned_to_transaction(tag_id);
-}}}
-
-Индексот `idx_transaction_date` е наменет за извештаи кои филтрираат трансакции според временски период. Индексот `idx_transaction_account_user_id` е наменет за извештаи кои ги ограничуваат резултатите на конкретен корисник. Индексите на `transaction_breakdown` и `tag_assigned_to_transaction` се наменети за побрзо поврзување на трансакциите со сметки и тагови.
-
-==== Сценарио 1: Трендови на трошење според тагови за корисник ====
-
-'''Цел:'''
-Овој извештај ја прикажува потрошувачката по тагови за конкретен корисник во последните шест месеци. Прашалникот е комплексен затоа што ги поврзува табелите `tag_assigned_to_transaction`, `transaction`, `transaction_breakdown`, `transaction_account` и `tag`, а дополнително врши филтрирање по корисник и датум, групирање по таг и месец, и сумирање на потрошената сума.
-
-'''Анализиран SQL:'''
-
-{{{#!sql
-EXPLAIN (ANALYZE, BUFFERS)
-SELECT
-tg.tag_name,
-DATE_TRUNC('month', t.date) AS month,
-SUM(tb.spent_amount) AS total_spent
-FROM tag_assigned_to_transaction tat
-JOIN "transaction" t
-ON tat.transaction_id = t.transaction_id
-JOIN transaction_breakdown tb
-ON t.transaction_id = tb.transaction_id
-JOIN transaction_account ta
-ON tb.transaction_account_id = ta.transaction_account_id
-JOIN tag tg
-ON tat.tag_id = tg.tag_id
-WHERE
-ta.user_id = 1
-AND t.date >= DATE_TRUNC('month', CURRENT_DATE - INTERVAL '6 MONTHS')
-GROUP BY
-tg.tag_name, month
-ORDER BY
-tg.tag_name, month;
-}}}
-
-'''Релевантни индекси за ова сценарио:'''
-
-{{{#!sql
-CREATE INDEX IF NOT EXISTS idx_transaction_date
-ON "transaction"(date);
-
-CREATE INDEX IF NOT EXISTS idx_transaction_account_user_id
-ON transaction_account(user_id);
-
-CREATE INDEX IF NOT EXISTS idx_transaction_breakdown_transaction_id
-ON transaction_breakdown(transaction_id);
-
-CREATE INDEX IF NOT EXISTS idx_transaction_breakdown_account_id
-ON transaction_breakdown(transaction_account_id);
-
-CREATE INDEX IF NOT EXISTS idx_tag_assigned_transaction_id
-ON tag_assigned_to_transaction(transaction_id);
-
-CREATE INDEX IF NOT EXISTS idx_tag_assigned_tag_id
-ON tag_assigned_to_transaction(tag_id);
-}}}
-
-'''План пред додавање на индексите:'''
-
-{{{#!sql
-GroupAggregate  (cost=6.54..6.77 rows=9 width=53) (actual time=0.176..0.189 rows=7 loops=1)
-Group Key: tg.tag_name, (date_trunc('month'::text, t.date))
-Buffers: shared hit=5
-->  Sort  (cost=6.54..6.57 rows=9 width=25) (actual time=0.162..0.167 rows=16 loops=1)
-Sort Key: tg.tag_name, (date_trunc('month'::text, t.date))
-Sort Method: quicksort  Memory: 25kB
-->  Hash Join  (cost=4.99..6.40 rows=9 width=25) (actual time=0.119..0.142 rows=16 loops=1)
-Hash Cond: (tat.tag_id = tg.tag_id)
-->  Hash Join  (cost=3.74..5.10 rows=9 width=16) (actual time=0.086..0.099 rows=16 loops=1)
-Hash Cond: (tat.transaction_id = t.transaction_id)
-->  Hash Join  (cost=2.35..3.67 rows=10 width=16) (actual time=0.057..0.066 rows=16 loops=1)
-Hash Cond: (tat.transaction_id = tb.transaction_id)
-->  Seq Scan on tag_assigned_to_transaction tat
-->  Hash
-->  Hash Join
-Hash Cond: (tb.transaction_account_id = ta.transaction_account_id)
-->  Seq Scan on transaction_breakdown tb
-->  Hash
-->  Seq Scan on transaction_account ta
-Filter: (user_id = 1)
-Rows Removed by Filter: 2
-->  Hash
-->  Seq Scan on transaction t
-Filter: (date >= date_trunc('month'::text, (CURRENT_DATE - '6 mons'::interval)))
-->  Hash
-->  Seq Scan on tag tg
-Planning Time: 0.733 ms
-Execution Time: 0.296 ms
-}}}
-
-'''План по додавање на индексите:'''
-
-{{{#!sql
-GroupAggregate  (cost=6.54..6.77 rows=9 width=53) (actual time=0.154..0.166 rows=7 loops=1)
-Group Key: tg.tag_name, (date_trunc('month'::text, t.date))
-Buffers: shared hit=5
-->  Sort  (cost=6.54..6.57 rows=9 width=25) (actual time=0.142..0.146 rows=16 loops=1)
-Sort Key: tg.tag_name, (date_trunc('month'::text, t.date))
-Sort Method: quicksort  Memory: 25kB
-->  Hash Join  (cost=4.99..6.40 rows=9 width=25) (actual time=0.100..0.122 rows=16 loops=1)
-Hash Cond: (tat.tag_id = tg.tag_id)
-->  Hash Join  (cost=3.74..5.10 rows=9 width=16) (actual time=0.068..0.081 rows=16 loops=1)
-Hash Cond: (tat.transaction_id = t.transaction_id)
-->  Hash Join  (cost=2.35..3.67 rows=10 width=16) (actual time=0.040..0.049 rows=16 loops=1)
-Hash Cond: (tat.transaction_id = tb.transaction_id)
-->  Seq Scan on tag_assigned_to_transaction tat
-->  Hash
-->  Hash Join
-Hash Cond: (tb.transaction_account_id = ta.transaction_account_id)
-->  Seq Scan on transaction_breakdown tb
-->  Hash
-->  Seq Scan on transaction_account ta
-Filter: (user_id = 1)
-Rows Removed by Filter: 2
-->  Hash
-->  Seq Scan on transaction t
-Filter: (date >= date_trunc('month'::text, (CURRENT_DATE - '6 mons'::interval)))
-->  Hash
-->  Seq Scan on tag tg
-Planning Time: 0.921 ms
-Execution Time: 0.242 ms
-}}}
-
-'''Споредба:'''
-
-||= Метрика =||= Пред индекси =||= По индекси =||
-|| Planning Time || 0.733 ms || 0.921 ms ||
-|| Execution Time || 0.296 ms || 0.242 ms ||
-|| Тип на скенирање || `Seq Scan` || `Seq Scan` ||
-|| Дали новите индекси се користат || Не || Не ||
-
-'''Заклучок:'''
-Времето на извршување се намали од 0.296 ms на 0.242 ms, што претставува намалување од приближно 18.2%. Сепак, execution plan-от и по додавање на индексите користи `Seq Scan` над сите релевантни табели, а не `Index Scan` или `Bitmap Index Scan`. Затоа подобрувањето не може да се припише директно на индексите. Најверојатната причина е малата количина на податоци и фактот што сите блокови се веќе во shared buffers. Индексите остануваат логични за идно зголемување на бројот на трансакции, тагови и breakdown записи.
-
-==== Сценарио 2: Сметки со највисоко вкупно трошење во изминатата година ====
-
-'''Цел:'''
-Овој извештај ги прикажува сметките со најголема вкупна потрошувачка во последната година. Прашалникот користи `JOIN` помеѓу `transaction_breakdown`, `transaction` и `transaction_account`, филтрирање по датум, `SUM` агрегација, `GROUP BY`, `ORDER BY` и `LIMIT`.
-
-'''Анализиран SQL:'''
-
-{{{#!sql
-EXPLAIN (ANALYZE, BUFFERS)
-SELECT
-ta.account_name,
-COALESCE(SUM(tb.spent_amount), 0) AS total_spent
-FROM transaction_breakdown tb
-JOIN "transaction" t
-ON tb.transaction_id = t.transaction_id
-JOIN transaction_account ta
-ON tb.transaction_account_id = ta.transaction_account_id
-WHERE
-t.date >= NOW() - INTERVAL '1 YEAR'
-GROUP BY
-ta.account_name
-ORDER BY
-total_spent DESC
-LIMIT 10;
-}}}
-
-'''Релевантни индекси за ова сценарио:'''
-
-{{{#!sql
-CREATE INDEX IF NOT EXISTS idx_transaction_date
-ON "transaction"(date);
-
-CREATE INDEX IF NOT EXISTS idx_transaction_breakdown_transaction_id
-ON transaction_breakdown(transaction_id);
-
-CREATE INDEX IF NOT EXISTS idx_transaction_breakdown_account_id
-ON transaction_breakdown(transaction_account_id);
-}}}
-
-'''План пред додавање на индексите:'''
-
-{{{#!sql
-Limit  (cost=3.85..3.86 rows=5 width=40) (actual time=0.110..0.114 rows=3 loops=1)
-Buffers: shared hit=3
-->  Sort  (cost=3.85..3.86 rows=5 width=40) (actual time=0.108..0.111 rows=3 loops=1)
-Sort Key: (COALESCE(sum(tb.spent_amount), '0'::numeric)) DESC
-Sort Method: quicksort  Memory: 25kB
-->  HashAggregate  (cost=3.73..3.79 rows=5 width=40) (actual time=0.097..0.101 rows=3 loops=1)
-Group Key: ta.account_name
-->  Hash Join  (cost=2.47..3.67 rows=11 width=12) (actual time=0.059..0.069 rows=11 loops=1)
-Hash Cond: (tb.transaction_account_id = ta.transaction_account_id)
-->  Hash Join  (cost=1.36..2.51 rows=11 width=8) (actual time=0.032..0.038 rows=11 loops=1)
-Hash Cond: (tb.transaction_id = t.transaction_id)
-->  Seq Scan on transaction_breakdown tb
-->  Hash
-->  Seq Scan on transaction t
-Filter: (date >= (now() - '1 year'::interval))
-->  Hash
-->  Seq Scan on transaction_account ta
-Planning Time: 0.403 ms
-Execution Time: 0.191 ms
-}}}
-
-'''План по додавање на индексите:'''
-
-{{{#!sql
-Limit  (cost=3.85..3.86 rows=5 width=40) (actual time=0.100..0.104 rows=3 loops=1)
-Buffers: shared hit=3
-->  Sort  (cost=3.85..3.86 rows=5 width=40) (actual time=0.098..0.101 rows=3 loops=1)
-Sort Key: (COALESCE(sum(tb.spent_amount), '0'::numeric)) DESC
-Sort Method: quicksort  Memory: 25kB
-->  HashAggregate  (cost=3.73..3.79 rows=5 width=40) (actual time=0.086..0.090 rows=3 loops=1)
-Group Key: ta.account_name
-->  Hash Join  (cost=2.47..3.67 rows=11 width=12) (actual time=0.060..0.070 rows=11 loops=1)
-Hash Cond: (tb.transaction_account_id = ta.transaction_account_id)
-->  Hash Join  (cost=1.36..2.51 rows=11 width=8) (actual time=0.030..0.036 rows=11 loops=1)
-Hash Cond: (tb.transaction_id = t.transaction_id)
-->  Seq Scan on transaction_breakdown tb
-->  Hash
-->  Seq Scan on transaction t
-Filter: (date >= (now() - '1 year'::interval))
-->  Hash
-->  Seq Scan on transaction_account ta
-Planning Time: 0.451 ms
-Execution Time: 0.166 ms
-}}}
-
-'''Споредба:'''
-
-||= Метрика =||= Пред индекси =||= По индекси =||
-|| Planning Time || 0.403 ms || 0.451 ms ||
-|| Execution Time || 0.191 ms || 0.166 ms ||
-|| Тип на скенирање || `Seq Scan` || `Seq Scan` ||
-|| Дали новите индекси се користат || Не || Не ||
-
-'''Заклучок:'''
-Времето на извршување се намали од 0.191 ms на 0.166 ms, што претставува намалување од приближно 13.1%. Сепак, планот за извршување не користи ниту еден од новите индекси. PostgreSQL продолжува со `Seq Scan` на `transaction`, `transaction_breakdown` и `transaction_account`, затоа што табелите имаат многу мал број редови и читањето на целата табела е поевтино од пристап преку индекс. Поради тоа, малото подобрување не се смета како директен ефект од индексите. Индексот на `transaction.date` сепак е оправдан за идно зголемување на бројот на трансакции, бидејќи извештајот филтрира според временски период.
-
-==== Сценарио 3: Годишни трендови на трансакции за корисник ====
-
-'''Цел:'''
-Овој извештај ја прикажува потрошувачката по квартали за конкретен корисник во тековната година. Прашалникот користи `JOIN` помеѓу `transaction_breakdown`, `transaction` и `transaction_account`, филтрирање по корисник и временски период, `SUM` агрегации со `CASE WHEN`, `GROUP BY` и `ORDER BY`.
-
-Во анализата условот за година е напишан како временски опсег, наместо само со `EXTRACT(YEAR FROM t.date)`, за B-Tree индексот на `date` да може поефикасно да се користи кога табелата ќе порасне.
-
-'''Анализиран SQL:'''
-
-{{{#!sql
-EXPLAIN (ANALYZE, BUFFERS)
-SELECT
-ta.account_name,
-SUM(CASE WHEN EXTRACT(QUARTER FROM t.date) = 1 THEN tb.spent_amount ELSE 0 END) AS q1_spent,
-SUM(CASE WHEN EXTRACT(QUARTER FROM t.date) = 2 THEN tb.spent_amount ELSE 0 END) AS q2_spent,
-SUM(CASE WHEN EXTRACT(QUARTER FROM t.date) = 3 THEN tb.spent_amount ELSE 0 END) AS q3_spent,
-SUM(CASE WHEN EXTRACT(QUARTER FROM t.date) = 4 THEN tb.spent_amount ELSE 0 END) AS q4_spent
-FROM transaction_breakdown tb
-JOIN "transaction" t
-ON tb.transaction_id = t.transaction_id
-JOIN transaction_account ta
-ON tb.transaction_account_id = ta.transaction_account_id
-WHERE
-ta.user_id = 1
-AND t.date >= DATE_TRUNC('year', CURRENT_DATE)
-AND t.date < DATE_TRUNC('year', CURRENT_DATE) + INTERVAL '1 YEAR'
-GROUP BY
-ta.account_name
-ORDER BY
-ta.account_name;
-}}}
-
-'''Релевантни индекси за ова сценарио:'''
-
-{{{#!sql
-CREATE INDEX IF NOT EXISTS idx_transaction_date
-ON "transaction"(date);
-
-CREATE INDEX IF NOT EXISTS idx_transaction_account_user_id
-ON transaction_account(user_id);
-
-CREATE INDEX IF NOT EXISTS idx_transaction_breakdown_transaction_id
-ON transaction_breakdown(transaction_id);
-
-CREATE INDEX IF NOT EXISTS idx_transaction_breakdown_account_id
-ON transaction_breakdown(transaction_account_id);
-}}}
-
-'''План пред додавање на индексите:'''
-
-{{{#!sql
-Sort  (cost=4.16..4.17 rows=3 width=136) (actual time=0.141..0.144 rows=3 loops=1)
-Sort Key: ta.account_name
-Sort Method: quicksort  Memory: 25kB
-Buffers: shared hit=3
-->  HashAggregate  (cost=4.08..4.14 rows=3 width=136) (actual time=0.126..0.131 rows=3 loops=1)
-Group Key: ta.account_name
-->  Hash Join  (cost=2.35..3.85 rows=7 width=20) (actual time=0.070..0.089 rows=11 loops=1)
-Hash Cond: (t.transaction_id = tb.transaction_id)
-->  Seq Scan on transaction t
-Filter: ((date >= date_trunc('year'::text, (CURRENT_DATE)::timestamp with time zone))
-AND (date < (date_trunc('year'::text, (CURRENT_DATE)::timestamp with time zone) + '1 year'::interval)))
-->  Hash
-->  Hash Join
-Hash Cond: (tb.transaction_account_id = ta.transaction_account_id)
-->  Seq Scan on transaction_breakdown tb
-->  Hash
-->  Seq Scan on transaction_account ta
-Filter: (user_id = 1)
-Rows Removed by Filter: 2
-Planning Time: 0.402 ms
-Execution Time: 0.242 ms
-}}}
-
-'''План по додавање на индексите:'''
-
-{{{#!sql
-Sort  (cost=4.16..4.17 rows=3 width=136) (actual time=0.131..0.135 rows=3 loops=1)
-Sort Key: ta.account_name
-Sort Method: quicksort  Memory: 25kB
-Buffers: shared hit=3
-->  HashAggregate  (cost=4.08..4.14 rows=3 width=136) (actual time=0.117..0.122 rows=3 loops=1)
-Group Key: ta.account_name
-->  Hash Join  (cost=2.35..3.85 rows=7 width=20) (actual time=0.063..0.082 rows=11 loops=1)
-Hash Cond: (t.transaction_id = tb.transaction_id)
-->  Seq Scan on transaction t
-Filter: ((date >= date_trunc('year'::text, (CURRENT_DATE)::timestamp with time zone))
-AND (date < (date_trunc('year'::text, (CURRENT_DATE)::timestamp with time zone) + '1 year'::interval)))
-->  Hash
-->  Hash Join
-Hash Cond: (tb.transaction_account_id = ta.transaction_account_id)
-->  Seq Scan on transaction_breakdown tb
-->  Hash
-->  Seq Scan on transaction_account ta
-Filter: (user_id = 1)
-Rows Removed by Filter: 2
-Planning Time: 0.500 ms
-Execution Time: 0.212 ms
-}}}
-
-'''Споредба:'''
-
-||= Метрика =||= Пред индекси =||= По индекси =||
-|| Planning Time || 0.402 ms || 0.500 ms ||
-|| Execution Time || 0.242 ms || 0.212 ms ||
-|| Тип на скенирање || `Seq Scan` || `Seq Scan` ||
-|| Дали новите индекси се користат || Не || Не ||
-
-'''Заклучок:'''
-Времето на извршување се намали од 0.242 ms на 0.212 ms, што претставува намалување од приближно 12.4%. Сепак, планот останува ист во однос на пристапот до табелите: PostgreSQL користи `Seq Scan`, а не новите индекси. Причината е малата количина на податоци: `transaction` има 12 редови, `transaction_breakdown` има 11 редови, а `transaction_account` има 5 редови. За ваква големина, секвенцијалното читање е поевтино од пристап преку индекс. Индексите сепак се соодветни за идно зголемување на базата, особено затоа што извештајот филтрира по `user_id` и по временски период.
-
-==== Финален заклучок од анализата ====
-
-Во сите три сценарија, PostgreSQL продолжи да користи `Seq Scan` и по додавање на предложените индекси. Тоа значи дека индексите не беа реално искористени во моменталниот execution plan.
-
-Ова однесување е очекувано поради малата количина на податоци во тест базата. Во моментот на тестирањето, најголемите релевантни табели имаат само 12 трансакции, 11 breakdown записи и 16 tag-assignment записи. За вакви табели, PostgreSQL правилно проценува дека целосно скенирање на табелата е поевтино од користење индекс.
-
-Иако execution time се намали во сите три сценарија, тоа не може директно да се припише на индексите, бидејќи execution plan-от не покажува `Index Scan`, `Bitmap Index Scan` или `Index Only Scan`. Малите разлики во времето најверојатно се резултат на кеширање, повторно користење на податоци во shared buffers и нормална варијација при извршување.
-
-Сепак, предложените индекси остануваат оправдани како подготовка за поголема количина на податоци. Со раст на бројот на корисници, трансакции, сметки, тагови и breakdown записи, индексите на `transaction.date`, `transaction_account.user_id`, `transaction_breakdown.transaction_id`, `transaction_breakdown.transaction_account_id`, `tag_assigned_to_transaction.transaction_id` и `tag_assigned_to_transaction.tag_id` ќе овозможат поефикасно филтрирање и поврзување на податоците во аналитичките извештаи.
-
-
-
Index: lint.config.mjs
===================================================================
--- eslint.config.mjs	(revision 5ee2d79a544d8f29386e3bbc597e35433bb9b89a)
+++ 	(revision )
@@ -1,9 +1,0 @@
-import { defineConfig, globalIgnores } from 'eslint/config';
-import nextVitals from 'eslint-config-next/core-web-vitals';
-
-const eslintConfig = defineConfig([
-    ...nextVitals,
-    globalIgnores(['.next/**', 'out/**', 'build/**', 'next-env.d.ts']),
-]);
-
-export default eslintConfig;
Index: ddleware.ts
===================================================================
--- middleware.ts	(revision 5ee2d79a544d8f29386e3bbc597e35433bb9b89a)
+++ 	(revision )
@@ -1,19 +1,0 @@
-import NextAuth from 'next-auth';
-import { authConfig } from './auth.config';
-
-export default NextAuth(authConfig).auth;
-
-export const config = {
-    // https://nextjs.org/docs/app/api-reference/file-conventions/proxy#matcher
-    matcher: [
-        /*
-          Match all routes except:
-          - api
-          - _next/static
-          - _next/image
-          - favicon
-          - png images
-        */
-        '/((?!api|_next/static|_next/image|favicon.ico|.*\\.png$).*)',
-    ],
-};
Index: xt.config.ts
===================================================================
--- next.config.ts	(revision 5ee2d79a544d8f29386e3bbc597e35433bb9b89a)
+++ 	(revision )
@@ -1,7 +1,0 @@
-import type { NextConfig } from 'next';
-
-const nextConfig: NextConfig = {
-  /* config options here */
-};
-
-export default nextConfig;
Index: ckage.json
===================================================================
--- package.json	(revision 5ee2d79a544d8f29386e3bbc597e35433bb9b89a)
+++ 	(revision )
@@ -1,42 +1,0 @@
-{
-  "private": true,
-  "scripts": {
-    "build": "next build",
-    "dev": "next dev --turbopack",
-    "start": "next start",
-    "lint": "eslint ."
-  },
-  "dependencies": {
-    "@heroicons/react": "^2.2.0",
-    "@tailwindcss/forms": "^0.5.11",
-    "autoprefixer": "10.4.23",
-    "bcrypt": "^6.0.0",
-    "bcryptjs": "^3.0.3",
-    "clsx": "^2.1.1",
-    "next": "15.5.9",
-    "next-auth": "5.0.0-beta.30",
-    "postgres": "^3.4.8",
-    "react": "latest",
-    "react-dom": "latest",
-    "tailwindcss": "4.1.18",
-    "typescript": "5.9.3",
-    "use-debounce": "^10.1.0",
-    "zod": "^4.3.5"
-  },
-  "devDependencies": {
-    "@tailwindcss/postcss": "^4.1.18",
-    "@types/bcrypt": "^6.0.0",
-    "@types/node": "25.0.9",
-    "@types/react": "19.2.8",
-    "@types/react-dom": "19.2.3",
-    "eslint": "^9.39.2",
-    "eslint-config-next": "^16.1.3",
-    "postcss": "8.5.6"
-  },
-  "pnpm": {
-    "onlyBuiltDependencies": [
-      "bcrypt",
-      "sharp"
-    ]
-  }
-}
Index: pm-lock.yaml
===================================================================
--- pnpm-lock.yaml	(revision 5ee2d79a544d8f29386e3bbc597e35433bb9b89a)
+++ 	(revision )
@@ -1,4224 +1,0 @@
-lockfileVersion: '9.0'
-
-settings:
-  autoInstallPeers: true
-  excludeLinksFromLockfile: false
-
-importers:
-
-  .:
-    dependencies:
-      '@heroicons/react':
-        specifier: ^2.2.0
-        version: 2.2.0(react@19.2.3)
-      '@tailwindcss/forms':
-        specifier: ^0.5.11
-        version: 0.5.11(tailwindcss@4.1.18)
-      autoprefixer:
-        specifier: 10.4.23
-        version: 10.4.23(postcss@8.5.6)
-      bcrypt:
-        specifier: ^6.0.0
-        version: 6.0.0
-      bcryptjs:
-        specifier: ^3.0.3
-        version: 3.0.3
-      clsx:
-        specifier: ^2.1.1
-        version: 2.1.1
-      next:
-        specifier: 15.5.9
-        version: 15.5.9(@babel/core@7.28.6)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
-      next-auth:
-        specifier: 5.0.0-beta.30
-        version: 5.0.0-beta.30(next@15.5.9(@babel/core@7.28.6)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)
-      postgres:
-        specifier: ^3.4.8
-        version: 3.4.8
-      react:
-        specifier: latest
-        version: 19.2.3
-      react-dom:
-        specifier: latest
-        version: 19.2.3(react@19.2.3)
-      tailwindcss:
-        specifier: 4.1.18
-        version: 4.1.18
-      typescript:
-        specifier: 5.9.3
-        version: 5.9.3
-      use-debounce:
-        specifier: ^10.1.0
-        version: 10.1.0(react@19.2.3)
-      zod:
-        specifier: ^4.3.5
-        version: 4.3.5
-    devDependencies:
-      '@tailwindcss/postcss':
-        specifier: ^4.1.18
-        version: 4.1.18
-      '@types/bcrypt':
-        specifier: ^6.0.0
-        version: 6.0.0
-      '@types/node':
-        specifier: 25.0.9
-        version: 25.0.9
-      '@types/react':
-        specifier: 19.2.8
-        version: 19.2.8
-      '@types/react-dom':
-        specifier: 19.2.3
-        version: 19.2.3(@types/react@19.2.8)
-      eslint:
-        specifier: ^9.39.2
-        version: 9.39.2(jiti@2.6.1)
-      eslint-config-next:
-        specifier: ^16.1.3
-        version: 16.1.3(@typescript-eslint/parser@8.53.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
-      postcss:
-        specifier: 8.5.6
-        version: 8.5.6
-
-packages:
-
-  '@alloc/quick-lru@5.2.0':
-    resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
-    engines: {node: '>=10'}
-
-  '@auth/core@0.41.0':
-    resolution: {integrity: sha512-Wd7mHPQ/8zy6Qj7f4T46vg3aoor8fskJm6g2Zyj064oQ3+p0xNZXAV60ww0hY+MbTesfu29kK14Zk5d5JTazXQ==}
-    peerDependencies:
-      '@simplewebauthn/browser': ^9.0.1
-      '@simplewebauthn/server': ^9.0.2
-      nodemailer: ^6.8.0
-    peerDependenciesMeta:
-      '@simplewebauthn/browser':
-        optional: true
-      '@simplewebauthn/server':
-        optional: true
-      nodemailer:
-        optional: true
-
-  '@babel/code-frame@7.28.6':
-    resolution: {integrity: sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==}
-    engines: {node: '>=6.9.0'}
-
-  '@babel/compat-data@7.28.6':
-    resolution: {integrity: sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==}
-    engines: {node: '>=6.9.0'}
-
-  '@babel/core@7.28.6':
-    resolution: {integrity: sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==}
-    engines: {node: '>=6.9.0'}
-
-  '@babel/generator@7.28.6':
-    resolution: {integrity: sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==}
-    engines: {node: '>=6.9.0'}
-
-  '@babel/helper-compilation-targets@7.28.6':
-    resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==}
-    engines: {node: '>=6.9.0'}
-
-  '@babel/helper-globals@7.28.0':
-    resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==}
-    engines: {node: '>=6.9.0'}
-
-  '@babel/helper-module-imports@7.28.6':
-    resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==}
-    engines: {node: '>=6.9.0'}
-
-  '@babel/helper-module-transforms@7.28.6':
-    resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==}
-    engines: {node: '>=6.9.0'}
-    peerDependencies:
-      '@babel/core': ^7.0.0
-
-  '@babel/helper-string-parser@7.27.1':
-    resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
-    engines: {node: '>=6.9.0'}
-
-  '@babel/helper-validator-identifier@7.28.5':
-    resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
-    engines: {node: '>=6.9.0'}
-
-  '@babel/helper-validator-option@7.27.1':
-    resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==}
-    engines: {node: '>=6.9.0'}
-
-  '@babel/helpers@7.28.6':
-    resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==}
-    engines: {node: '>=6.9.0'}
-
-  '@babel/parser@7.28.6':
-    resolution: {integrity: sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==}
-    engines: {node: '>=6.0.0'}
-    hasBin: true
-
-  '@babel/template@7.28.6':
-    resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
-    engines: {node: '>=6.9.0'}
-
-  '@babel/traverse@7.28.6':
-    resolution: {integrity: sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==}
-    engines: {node: '>=6.9.0'}
-
-  '@babel/types@7.28.6':
-    resolution: {integrity: sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==}
-    engines: {node: '>=6.9.0'}
-
-  '@emnapi/core@1.8.1':
-    resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==}
-
-  '@emnapi/runtime@1.8.1':
-    resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==}
-
-  '@emnapi/wasi-threads@1.1.0':
-    resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==}
-
-  '@eslint-community/eslint-utils@4.9.1':
-    resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==}
-    engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
-    peerDependencies:
-      eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
-
-  '@eslint-community/regexpp@4.12.2':
-    resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==}
-    engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
-
-  '@eslint/config-array@0.21.1':
-    resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==}
-    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
-
-  '@eslint/config-helpers@0.4.2':
-    resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==}
-    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
-
-  '@eslint/core@0.17.0':
-    resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==}
-    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
-
-  '@eslint/eslintrc@3.3.3':
-    resolution: {integrity: sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==}
-    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
-
-  '@eslint/js@9.39.2':
-    resolution: {integrity: sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==}
-    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
-
-  '@eslint/object-schema@2.1.7':
-    resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==}
-    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
-
-  '@eslint/plugin-kit@0.4.1':
-    resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
-    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
-
-  '@heroicons/react@2.2.0':
-    resolution: {integrity: sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==}
-    peerDependencies:
-      react: '>= 16 || ^19.0.0-rc'
-
-  '@humanfs/core@0.19.1':
-    resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
-    engines: {node: '>=18.18.0'}
-
-  '@humanfs/node@0.16.7':
-    resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==}
-    engines: {node: '>=18.18.0'}
-
-  '@humanwhocodes/module-importer@1.0.1':
-    resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==}
-    engines: {node: '>=12.22'}
-
-  '@humanwhocodes/retry@0.4.3':
-    resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
-    engines: {node: '>=18.18'}
-
-  '@img/colour@1.0.0':
-    resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==}
-    engines: {node: '>=18'}
-
-  '@img/sharp-darwin-arm64@0.34.5':
-    resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
-    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
-    cpu: [arm64]
-    os: [darwin]
-
-  '@img/sharp-darwin-x64@0.34.5':
-    resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
-    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
-    cpu: [x64]
-    os: [darwin]
-
-  '@img/sharp-libvips-darwin-arm64@1.2.4':
-    resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
-    cpu: [arm64]
-    os: [darwin]
-
-  '@img/sharp-libvips-darwin-x64@1.2.4':
-    resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
-    cpu: [x64]
-    os: [darwin]
-
-  '@img/sharp-libvips-linux-arm64@1.2.4':
-    resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
-    cpu: [arm64]
-    os: [linux]
-
-  '@img/sharp-libvips-linux-arm@1.2.4':
-    resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
-    cpu: [arm]
-    os: [linux]
-
-  '@img/sharp-libvips-linux-ppc64@1.2.4':
-    resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
-    cpu: [ppc64]
-    os: [linux]
-
-  '@img/sharp-libvips-linux-riscv64@1.2.4':
-    resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
-    cpu: [riscv64]
-    os: [linux]
-
-  '@img/sharp-libvips-linux-s390x@1.2.4':
-    resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
-    cpu: [s390x]
-    os: [linux]
-
-  '@img/sharp-libvips-linux-x64@1.2.4':
-    resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
-    cpu: [x64]
-    os: [linux]
-
-  '@img/sharp-libvips-linuxmusl-arm64@1.2.4':
-    resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
-    cpu: [arm64]
-    os: [linux]
-
-  '@img/sharp-libvips-linuxmusl-x64@1.2.4':
-    resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
-    cpu: [x64]
-    os: [linux]
-
-  '@img/sharp-linux-arm64@0.34.5':
-    resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
-    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
-    cpu: [arm64]
-    os: [linux]
-
-  '@img/sharp-linux-arm@0.34.5':
-    resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
-    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
-    cpu: [arm]
-    os: [linux]
-
-  '@img/sharp-linux-ppc64@0.34.5':
-    resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
-    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
-    cpu: [ppc64]
-    os: [linux]
-
-  '@img/sharp-linux-riscv64@0.34.5':
-    resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
-    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
-    cpu: [riscv64]
-    os: [linux]
-
-  '@img/sharp-linux-s390x@0.34.5':
-    resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
-    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
-    cpu: [s390x]
-    os: [linux]
-
-  '@img/sharp-linux-x64@0.34.5':
-    resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
-    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
-    cpu: [x64]
-    os: [linux]
-
-  '@img/sharp-linuxmusl-arm64@0.34.5':
-    resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
-    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
-    cpu: [arm64]
-    os: [linux]
-
-  '@img/sharp-linuxmusl-x64@0.34.5':
-    resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
-    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
-    cpu: [x64]
-    os: [linux]
-
-  '@img/sharp-wasm32@0.34.5':
-    resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
-    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
-    cpu: [wasm32]
-
-  '@img/sharp-win32-arm64@0.34.5':
-    resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
-    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
-    cpu: [arm64]
-    os: [win32]
-
-  '@img/sharp-win32-ia32@0.34.5':
-    resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
-    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
-    cpu: [ia32]
-    os: [win32]
-
-  '@img/sharp-win32-x64@0.34.5':
-    resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
-    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
-    cpu: [x64]
-    os: [win32]
-
-  '@jridgewell/gen-mapping@0.3.13':
-    resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
-
-  '@jridgewell/remapping@2.3.5':
-    resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
-
-  '@jridgewell/resolve-uri@3.1.2':
-    resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
-    engines: {node: '>=6.0.0'}
-
-  '@jridgewell/sourcemap-codec@1.5.5':
-    resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
-
-  '@jridgewell/trace-mapping@0.3.31':
-    resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
-
-  '@napi-rs/wasm-runtime@0.2.12':
-    resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
-
-  '@next/env@15.5.9':
-    resolution: {integrity: sha512-4GlTZ+EJM7WaW2HEZcyU317tIQDjkQIyENDLxYJfSWlfqguN+dHkZgyQTV/7ykvobU7yEH5gKvreNrH4B6QgIg==}
-
-  '@next/eslint-plugin-next@16.1.3':
-    resolution: {integrity: sha512-MqBh3ltFAy0AZCRFVdjVjjeV7nEszJDaVIpDAnkQcn8U9ib6OEwkSnuK6xdYxMGPhV/Y4IlY6RbDipPOpLfBqQ==}
-
-  '@next/swc-darwin-arm64@15.5.7':
-    resolution: {integrity: sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw==}
-    engines: {node: '>= 10'}
-    cpu: [arm64]
-    os: [darwin]
-
-  '@next/swc-darwin-x64@15.5.7':
-    resolution: {integrity: sha512-UP6CaDBcqaCBuiq/gfCEJw7sPEoX1aIjZHnBWN9v9qYHQdMKvCKcAVs4OX1vIjeE+tC5EIuwDTVIoXpUes29lg==}
-    engines: {node: '>= 10'}
-    cpu: [x64]
-    os: [darwin]
-
-  '@next/swc-linux-arm64-gnu@15.5.7':
-    resolution: {integrity: sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA==}
-    engines: {node: '>= 10'}
-    cpu: [arm64]
-    os: [linux]
-
-  '@next/swc-linux-arm64-musl@15.5.7':
-    resolution: {integrity: sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw==}
-    engines: {node: '>= 10'}
-    cpu: [arm64]
-    os: [linux]
-
-  '@next/swc-linux-x64-gnu@15.5.7':
-    resolution: {integrity: sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw==}
-    engines: {node: '>= 10'}
-    cpu: [x64]
-    os: [linux]
-
-  '@next/swc-linux-x64-musl@15.5.7':
-    resolution: {integrity: sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA==}
-    engines: {node: '>= 10'}
-    cpu: [x64]
-    os: [linux]
-
-  '@next/swc-win32-arm64-msvc@15.5.7':
-    resolution: {integrity: sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ==}
-    engines: {node: '>= 10'}
-    cpu: [arm64]
-    os: [win32]
-
-  '@next/swc-win32-x64-msvc@15.5.7':
-    resolution: {integrity: sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw==}
-    engines: {node: '>= 10'}
-    cpu: [x64]
-    os: [win32]
-
-  '@nodelib/fs.scandir@2.1.5':
-    resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
-    engines: {node: '>= 8'}
-
-  '@nodelib/fs.stat@2.0.5':
-    resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==}
-    engines: {node: '>= 8'}
-
-  '@nodelib/fs.walk@1.2.8':
-    resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
-    engines: {node: '>= 8'}
-
-  '@nolyfill/is-core-module@1.0.39':
-    resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
-    engines: {node: '>=12.4.0'}
-
-  '@panva/hkdf@1.2.1':
-    resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==}
-
-  '@rtsao/scc@1.1.0':
-    resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
-
-  '@swc/helpers@0.5.15':
-    resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
-
-  '@tailwindcss/forms@0.5.11':
-    resolution: {integrity: sha512-h9wegbZDPurxG22xZSoWtdzc41/OlNEUQERNqI/0fOwa2aVlWGu7C35E/x6LDyD3lgtztFSSjKZyuVM0hxhbgA==}
-    peerDependencies:
-      tailwindcss: '>=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1'
-
-  '@tailwindcss/node@4.1.18':
-    resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==}
-
-  '@tailwindcss/oxide-android-arm64@4.1.18':
-    resolution: {integrity: sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==}
-    engines: {node: '>= 10'}
-    cpu: [arm64]
-    os: [android]
-
-  '@tailwindcss/oxide-darwin-arm64@4.1.18':
-    resolution: {integrity: sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==}
-    engines: {node: '>= 10'}
-    cpu: [arm64]
-    os: [darwin]
-
-  '@tailwindcss/oxide-darwin-x64@4.1.18':
-    resolution: {integrity: sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==}
-    engines: {node: '>= 10'}
-    cpu: [x64]
-    os: [darwin]
-
-  '@tailwindcss/oxide-freebsd-x64@4.1.18':
-    resolution: {integrity: sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==}
-    engines: {node: '>= 10'}
-    cpu: [x64]
-    os: [freebsd]
-
-  '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18':
-    resolution: {integrity: sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==}
-    engines: {node: '>= 10'}
-    cpu: [arm]
-    os: [linux]
-
-  '@tailwindcss/oxide-linux-arm64-gnu@4.1.18':
-    resolution: {integrity: sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==}
-    engines: {node: '>= 10'}
-    cpu: [arm64]
-    os: [linux]
-
-  '@tailwindcss/oxide-linux-arm64-musl@4.1.18':
-    resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==}
-    engines: {node: '>= 10'}
-    cpu: [arm64]
-    os: [linux]
-
-  '@tailwindcss/oxide-linux-x64-gnu@4.1.18':
-    resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==}
-    engines: {node: '>= 10'}
-    cpu: [x64]
-    os: [linux]
-
-  '@tailwindcss/oxide-linux-x64-musl@4.1.18':
-    resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==}
-    engines: {node: '>= 10'}
-    cpu: [x64]
-    os: [linux]
-
-  '@tailwindcss/oxide-wasm32-wasi@4.1.18':
-    resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==}
-    engines: {node: '>=14.0.0'}
-    cpu: [wasm32]
-    bundledDependencies:
-      - '@napi-rs/wasm-runtime'
-      - '@emnapi/core'
-      - '@emnapi/runtime'
-      - '@tybys/wasm-util'
-      - '@emnapi/wasi-threads'
-      - tslib
-
-  '@tailwindcss/oxide-win32-arm64-msvc@4.1.18':
-    resolution: {integrity: sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==}
-    engines: {node: '>= 10'}
-    cpu: [arm64]
-    os: [win32]
-
-  '@tailwindcss/oxide-win32-x64-msvc@4.1.18':
-    resolution: {integrity: sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==}
-    engines: {node: '>= 10'}
-    cpu: [x64]
-    os: [win32]
-
-  '@tailwindcss/oxide@4.1.18':
-    resolution: {integrity: sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==}
-    engines: {node: '>= 10'}
-
-  '@tailwindcss/postcss@4.1.18':
-    resolution: {integrity: sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==}
-
-  '@tybys/wasm-util@0.10.1':
-    resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
-
-  '@types/bcrypt@6.0.0':
-    resolution: {integrity: sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==}
-
-  '@types/estree@1.0.8':
-    resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
-
-  '@types/json-schema@7.0.15':
-    resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
-
-  '@types/json5@0.0.29':
-    resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
-
-  '@types/node@25.0.9':
-    resolution: {integrity: sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw==}
-
-  '@types/react-dom@19.2.3':
-    resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
-    peerDependencies:
-      '@types/react': ^19.2.0
-
-  '@types/react@19.2.8':
-    resolution: {integrity: sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==}
-
-  '@typescript-eslint/eslint-plugin@8.53.1':
-    resolution: {integrity: sha512-cFYYFZ+oQFi6hUnBTbLRXfTJiaQtYE3t4O692agbBl+2Zy+eqSKWtPjhPXJu1G7j4RLjKgeJPDdq3EqOwmX5Ag==}
-    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
-    peerDependencies:
-      '@typescript-eslint/parser': ^8.53.1
-      eslint: ^8.57.0 || ^9.0.0
-      typescript: '>=4.8.4 <6.0.0'
-
-  '@typescript-eslint/parser@8.53.1':
-    resolution: {integrity: sha512-nm3cvFN9SqZGXjmw5bZ6cGmvJSyJPn0wU9gHAZZHDnZl2wF9PhHv78Xf06E0MaNk4zLVHL8hb2/c32XvyJOLQg==}
-    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
-    peerDependencies:
-      eslint: ^8.57.0 || ^9.0.0
-      typescript: '>=4.8.4 <6.0.0'
-
-  '@typescript-eslint/project-service@8.53.1':
-    resolution: {integrity: sha512-WYC4FB5Ra0xidsmlPb+1SsnaSKPmS3gsjIARwbEkHkoWloQmuzcfypljaJcR78uyLA1h8sHdWWPHSLDI+MtNog==}
-    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
-    peerDependencies:
-      typescript: '>=4.8.4 <6.0.0'
-
-  '@typescript-eslint/scope-manager@8.53.1':
-    resolution: {integrity: sha512-Lu23yw1uJMFY8cUeq7JlrizAgeQvWugNQzJp8C3x8Eo5Jw5Q2ykMdiiTB9vBVOOUBysMzmRRmUfwFrZuI2C4SQ==}
-    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
-
-  '@typescript-eslint/tsconfig-utils@8.53.1':
-    resolution: {integrity: sha512-qfvLXS6F6b1y43pnf0pPbXJ+YoXIC7HKg0UGZ27uMIemKMKA6XH2DTxsEDdpdN29D+vHV07x/pnlPNVLhdhWiA==}
-    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
-    peerDependencies:
-      typescript: '>=4.8.4 <6.0.0'
-
-  '@typescript-eslint/type-utils@8.53.1':
-    resolution: {integrity: sha512-MOrdtNvyhy0rHyv0ENzub1d4wQYKb2NmIqG7qEqPWFW7Mpy2jzFC3pQ2yKDvirZB7jypm5uGjF2Qqs6OIqu47w==}
-    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
-    peerDependencies:
-      eslint: ^8.57.0 || ^9.0.0
-      typescript: '>=4.8.4 <6.0.0'
-
-  '@typescript-eslint/types@8.53.1':
-    resolution: {integrity: sha512-jr/swrr2aRmUAUjW5/zQHbMaui//vQlsZcJKijZf3M26bnmLj8LyZUpj8/Rd6uzaek06OWsqdofN/Thenm5O8A==}
-    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
-
-  '@typescript-eslint/typescript-estree@8.53.1':
-    resolution: {integrity: sha512-RGlVipGhQAG4GxV1s34O91cxQ/vWiHJTDHbXRr0li2q/BGg3RR/7NM8QDWgkEgrwQYCvmJV9ichIwyoKCQ+DTg==}
-    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
-    peerDependencies:
-      typescript: '>=4.8.4 <6.0.0'
-
-  '@typescript-eslint/utils@8.53.1':
-    resolution: {integrity: sha512-c4bMvGVWW4hv6JmDUEG7fSYlWOl3II2I4ylt0NM+seinYQlZMQIaKaXIIVJWt9Ofh6whrpM+EdDQXKXjNovvrg==}
-    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
-    peerDependencies:
-      eslint: ^8.57.0 || ^9.0.0
-      typescript: '>=4.8.4 <6.0.0'
-
-  '@typescript-eslint/visitor-keys@8.53.1':
-    resolution: {integrity: sha512-oy+wV7xDKFPRyNggmXuZQSBzvoLnpmJs+GhzRhPjrxl2b/jIlyjVokzm47CZCDUdXKr2zd7ZLodPfOBpOPyPlg==}
-    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
-
-  '@unrs/resolver-binding-android-arm-eabi@1.11.1':
-    resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==}
-    cpu: [arm]
-    os: [android]
-
-  '@unrs/resolver-binding-android-arm64@1.11.1':
-    resolution: {integrity: sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==}
-    cpu: [arm64]
-    os: [android]
-
-  '@unrs/resolver-binding-darwin-arm64@1.11.1':
-    resolution: {integrity: sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==}
-    cpu: [arm64]
-    os: [darwin]
-
-  '@unrs/resolver-binding-darwin-x64@1.11.1':
-    resolution: {integrity: sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==}
-    cpu: [x64]
-    os: [darwin]
-
-  '@unrs/resolver-binding-freebsd-x64@1.11.1':
-    resolution: {integrity: sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==}
-    cpu: [x64]
-    os: [freebsd]
-
-  '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1':
-    resolution: {integrity: sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==}
-    cpu: [arm]
-    os: [linux]
-
-  '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1':
-    resolution: {integrity: sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==}
-    cpu: [arm]
-    os: [linux]
-
-  '@unrs/resolver-binding-linux-arm64-gnu@1.11.1':
-    resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
-    cpu: [arm64]
-    os: [linux]
-
-  '@unrs/resolver-binding-linux-arm64-musl@1.11.1':
-    resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
-    cpu: [arm64]
-    os: [linux]
-
-  '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
-    resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
-    cpu: [ppc64]
-    os: [linux]
-
-  '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
-    resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
-    cpu: [riscv64]
-    os: [linux]
-
-  '@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
-    resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
-    cpu: [riscv64]
-    os: [linux]
-
-  '@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
-    resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
-    cpu: [s390x]
-    os: [linux]
-
-  '@unrs/resolver-binding-linux-x64-gnu@1.11.1':
-    resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
-    cpu: [x64]
-    os: [linux]
-
-  '@unrs/resolver-binding-linux-x64-musl@1.11.1':
-    resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
-    cpu: [x64]
-    os: [linux]
-
-  '@unrs/resolver-binding-wasm32-wasi@1.11.1':
-    resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
-    engines: {node: '>=14.0.0'}
-    cpu: [wasm32]
-
-  '@unrs/resolver-binding-win32-arm64-msvc@1.11.1':
-    resolution: {integrity: sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==}
-    cpu: [arm64]
-    os: [win32]
-
-  '@unrs/resolver-binding-win32-ia32-msvc@1.11.1':
-    resolution: {integrity: sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==}
-    cpu: [ia32]
-    os: [win32]
-
-  '@unrs/resolver-binding-win32-x64-msvc@1.11.1':
-    resolution: {integrity: sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==}
-    cpu: [x64]
-    os: [win32]
-
-  acorn-jsx@5.3.2:
-    resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
-    peerDependencies:
-      acorn: ^6.0.0 || ^7.0.0 || ^8.0.0
-
-  acorn@8.15.0:
-    resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==}
-    engines: {node: '>=0.4.0'}
-    hasBin: true
-
-  ajv@6.12.6:
-    resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
-
-  ansi-styles@4.3.0:
-    resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
-    engines: {node: '>=8'}
-
-  argparse@2.0.1:
-    resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
-
-  aria-query@5.3.2:
-    resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
-    engines: {node: '>= 0.4'}
-
-  array-buffer-byte-length@1.0.2:
-    resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==}
-    engines: {node: '>= 0.4'}
-
-  array-includes@3.1.9:
-    resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==}
-    engines: {node: '>= 0.4'}
-
-  array.prototype.findlast@1.2.5:
-    resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==}
-    engines: {node: '>= 0.4'}
-
-  array.prototype.findlastindex@1.2.6:
-    resolution: {integrity: sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==}
-    engines: {node: '>= 0.4'}
-
-  array.prototype.flat@1.3.3:
-    resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==}
-    engines: {node: '>= 0.4'}
-
-  array.prototype.flatmap@1.3.3:
-    resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==}
-    engines: {node: '>= 0.4'}
-
-  array.prototype.tosorted@1.1.4:
-    resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==}
-    engines: {node: '>= 0.4'}
-
-  arraybuffer.prototype.slice@1.0.4:
-    resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==}
-    engines: {node: '>= 0.4'}
-
-  ast-types-flow@0.0.8:
-    resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==}
-
-  async-function@1.0.0:
-    resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
-    engines: {node: '>= 0.4'}
-
-  autoprefixer@10.4.23:
-    resolution: {integrity: sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==}
-    engines: {node: ^10 || ^12 || >=14}
-    hasBin: true
-    peerDependencies:
-      postcss: ^8.1.0
-
-  available-typed-arrays@1.0.7:
-    resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
-    engines: {node: '>= 0.4'}
-
-  axe-core@4.11.1:
-    resolution: {integrity: sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==}
-    engines: {node: '>=4'}
-
-  axobject-query@4.1.0:
-    resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
-    engines: {node: '>= 0.4'}
-
-  balanced-match@1.0.2:
-    resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
-
-  baseline-browser-mapping@2.9.15:
-    resolution: {integrity: sha512-kX8h7K2srmDyYnXRIppo4AH/wYgzWVCs+eKr3RusRSQ5PvRYoEFmR/I0PbdTjKFAoKqp5+kbxnNTFO9jOfSVJg==}
-    hasBin: true
-
-  bcrypt@6.0.0:
-    resolution: {integrity: sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==}
-    engines: {node: '>= 18'}
-
-  bcryptjs@3.0.3:
-    resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==}
-    hasBin: true
-
-  brace-expansion@1.1.12:
-    resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
-
-  brace-expansion@2.0.2:
-    resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
-
-  braces@3.0.3:
-    resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
-    engines: {node: '>=8'}
-
-  browserslist@4.28.1:
-    resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==}
-    engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
-    hasBin: true
-
-  call-bind-apply-helpers@1.0.2:
-    resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
-    engines: {node: '>= 0.4'}
-
-  call-bind@1.0.8:
-    resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==}
-    engines: {node: '>= 0.4'}
-
-  call-bound@1.0.4:
-    resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
-    engines: {node: '>= 0.4'}
-
-  callsites@3.1.0:
-    resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
-    engines: {node: '>=6'}
-
-  caniuse-lite@1.0.30001765:
-    resolution: {integrity: sha512-LWcNtSyZrakjECqmpP4qdg0MMGdN368D7X8XvvAqOcqMv0RxnlqVKZl2V6/mBR68oYMxOZPLw/gO7DuisMHUvQ==}
-
-  chalk@4.1.2:
-    resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
-    engines: {node: '>=10'}
-
-  client-only@0.0.1:
-    resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
-
-  clsx@2.1.1:
-    resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
-    engines: {node: '>=6'}
-
-  color-convert@2.0.1:
-    resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
-    engines: {node: '>=7.0.0'}
-
-  color-name@1.1.4:
-    resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
-
-  concat-map@0.0.1:
-    resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
-
-  convert-source-map@2.0.0:
-    resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
-
-  cross-spawn@7.0.6:
-    resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
-    engines: {node: '>= 8'}
-
-  csstype@3.2.3:
-    resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
-
-  damerau-levenshtein@1.0.8:
-    resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
-
-  data-view-buffer@1.0.2:
-    resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==}
-    engines: {node: '>= 0.4'}
-
-  data-view-byte-length@1.0.2:
-    resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==}
-    engines: {node: '>= 0.4'}
-
-  data-view-byte-offset@1.0.1:
-    resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==}
-    engines: {node: '>= 0.4'}
-
-  debug@3.2.7:
-    resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
-    peerDependencies:
-      supports-color: '*'
-    peerDependenciesMeta:
-      supports-color:
-        optional: true
-
-  debug@4.4.3:
-    resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
-    engines: {node: '>=6.0'}
-    peerDependencies:
-      supports-color: '*'
-    peerDependenciesMeta:
-      supports-color:
-        optional: true
-
-  deep-is@0.1.4:
-    resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
-
-  define-data-property@1.1.4:
-    resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==}
-    engines: {node: '>= 0.4'}
-
-  define-properties@1.2.1:
-    resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
-    engines: {node: '>= 0.4'}
-
-  detect-libc@2.1.2:
-    resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
-    engines: {node: '>=8'}
-
-  doctrine@2.1.0:
-    resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
-    engines: {node: '>=0.10.0'}
-
-  dunder-proto@1.0.1:
-    resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
-    engines: {node: '>= 0.4'}
-
-  electron-to-chromium@1.5.267:
-    resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==}
-
-  emoji-regex@9.2.2:
-    resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
-
-  enhanced-resolve@5.18.4:
-    resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==}
-    engines: {node: '>=10.13.0'}
-
-  es-abstract@1.24.1:
-    resolution: {integrity: sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==}
-    engines: {node: '>= 0.4'}
-
-  es-define-property@1.0.1:
-    resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
-    engines: {node: '>= 0.4'}
-
-  es-errors@1.3.0:
-    resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
-    engines: {node: '>= 0.4'}
-
-  es-iterator-helpers@1.2.2:
-    resolution: {integrity: sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==}
-    engines: {node: '>= 0.4'}
-
-  es-object-atoms@1.1.1:
-    resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
-    engines: {node: '>= 0.4'}
-
-  es-set-tostringtag@2.1.0:
-    resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
-    engines: {node: '>= 0.4'}
-
-  es-shim-unscopables@1.1.0:
-    resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==}
-    engines: {node: '>= 0.4'}
-
-  es-to-primitive@1.3.0:
-    resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==}
-    engines: {node: '>= 0.4'}
-
-  escalade@3.2.0:
-    resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
-    engines: {node: '>=6'}
-
-  escape-string-regexp@4.0.0:
-    resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
-    engines: {node: '>=10'}
-
-  eslint-config-next@16.1.3:
-    resolution: {integrity: sha512-q2Z87VSsoJcv+vgR+Dm8NPRf+rErXcRktuBR5y3umo/j5zLjIWH7rqBCh3X804gUGKbOrqbgsLUkqDE35C93Gw==}
-    peerDependencies:
-      eslint: '>=9.0.0'
-      typescript: '>=3.3.1'
-    peerDependenciesMeta:
-      typescript:
-        optional: true
-
-  eslint-import-resolver-node@0.3.9:
-    resolution: {integrity: sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==}
-
-  eslint-import-resolver-typescript@3.10.1:
-    resolution: {integrity: sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==}
-    engines: {node: ^14.18.0 || >=16.0.0}
-    peerDependencies:
-      eslint: '*'
-      eslint-plugin-import: '*'
-      eslint-plugin-import-x: '*'
-    peerDependenciesMeta:
-      eslint-plugin-import:
-        optional: true
-      eslint-plugin-import-x:
-        optional: true
-
-  eslint-module-utils@2.12.1:
-    resolution: {integrity: sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==}
-    engines: {node: '>=4'}
-    peerDependencies:
-      '@typescript-eslint/parser': '*'
-      eslint: '*'
-      eslint-import-resolver-node: '*'
-      eslint-import-resolver-typescript: '*'
-      eslint-import-resolver-webpack: '*'
-    peerDependenciesMeta:
-      '@typescript-eslint/parser':
-        optional: true
-      eslint:
-        optional: true
-      eslint-import-resolver-node:
-        optional: true
-      eslint-import-resolver-typescript:
-        optional: true
-      eslint-import-resolver-webpack:
-        optional: true
-
-  eslint-plugin-import@2.32.0:
-    resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==}
-    engines: {node: '>=4'}
-    peerDependencies:
-      '@typescript-eslint/parser': '*'
-      eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9
-    peerDependenciesMeta:
-      '@typescript-eslint/parser':
-        optional: true
-
-  eslint-plugin-jsx-a11y@6.10.2:
-    resolution: {integrity: sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==}
-    engines: {node: '>=4.0'}
-    peerDependencies:
-      eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9
-
-  eslint-plugin-react-hooks@7.0.1:
-    resolution: {integrity: sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==}
-    engines: {node: '>=18'}
-    peerDependencies:
-      eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0
-
-  eslint-plugin-react@7.37.5:
-    resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==}
-    engines: {node: '>=4'}
-    peerDependencies:
-      eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7
-
-  eslint-scope@8.4.0:
-    resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==}
-    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
-
-  eslint-visitor-keys@3.4.3:
-    resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==}
-    engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
-
-  eslint-visitor-keys@4.2.1:
-    resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==}
-    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
-
-  eslint@9.39.2:
-    resolution: {integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==}
-    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
-    hasBin: true
-    peerDependencies:
-      jiti: '*'
-    peerDependenciesMeta:
-      jiti:
-        optional: true
-
-  espree@10.4.0:
-    resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==}
-    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
-
-  esquery@1.7.0:
-    resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==}
-    engines: {node: '>=0.10'}
-
-  esrecurse@4.3.0:
-    resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==}
-    engines: {node: '>=4.0'}
-
-  estraverse@5.3.0:
-    resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
-    engines: {node: '>=4.0'}
-
-  esutils@2.0.3:
-    resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
-    engines: {node: '>=0.10.0'}
-
-  fast-deep-equal@3.1.3:
-    resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
-
-  fast-glob@3.3.1:
-    resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==}
-    engines: {node: '>=8.6.0'}
-
-  fast-json-stable-stringify@2.1.0:
-    resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
-
-  fast-levenshtein@2.0.6:
-    resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
-
-  fastq@1.20.1:
-    resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
-
-  fdir@6.5.0:
-    resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
-    engines: {node: '>=12.0.0'}
-    peerDependencies:
-      picomatch: ^3 || ^4
-    peerDependenciesMeta:
-      picomatch:
-        optional: true
-
-  file-entry-cache@8.0.0:
-    resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
-    engines: {node: '>=16.0.0'}
-
-  fill-range@7.1.1:
-    resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
-    engines: {node: '>=8'}
-
-  find-up@5.0.0:
-    resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
-    engines: {node: '>=10'}
-
-  flat-cache@4.0.1:
-    resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==}
-    engines: {node: '>=16'}
-
-  flatted@3.3.3:
-    resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
-
-  for-each@0.3.5:
-    resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
-    engines: {node: '>= 0.4'}
-
-  fraction.js@5.3.4:
-    resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
-
-  function-bind@1.1.2:
-    resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
-
-  function.prototype.name@1.1.8:
-    resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==}
-    engines: {node: '>= 0.4'}
-
-  functions-have-names@1.2.3:
-    resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
-
-  generator-function@2.0.1:
-    resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==}
-    engines: {node: '>= 0.4'}
-
-  gensync@1.0.0-beta.2:
-    resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
-    engines: {node: '>=6.9.0'}
-
-  get-intrinsic@1.3.0:
-    resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
-    engines: {node: '>= 0.4'}
-
-  get-proto@1.0.1:
-    resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
-    engines: {node: '>= 0.4'}
-
-  get-symbol-description@1.1.0:
-    resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==}
-    engines: {node: '>= 0.4'}
-
-  get-tsconfig@4.13.0:
-    resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==}
-
-  glob-parent@5.1.2:
-    resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
-    engines: {node: '>= 6'}
-
-  glob-parent@6.0.2:
-    resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
-    engines: {node: '>=10.13.0'}
-
-  globals@14.0.0:
-    resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
-    engines: {node: '>=18'}
-
-  globals@16.4.0:
-    resolution: {integrity: sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==}
-    engines: {node: '>=18'}
-
-  globalthis@1.0.4:
-    resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==}
-    engines: {node: '>= 0.4'}
-
-  gopd@1.2.0:
-    resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
-    engines: {node: '>= 0.4'}
-
-  graceful-fs@4.2.11:
-    resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
-
-  has-bigints@1.1.0:
-    resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==}
-    engines: {node: '>= 0.4'}
-
-  has-flag@4.0.0:
-    resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
-    engines: {node: '>=8'}
-
-  has-property-descriptors@1.0.2:
-    resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==}
-
-  has-proto@1.2.0:
-    resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==}
-    engines: {node: '>= 0.4'}
-
-  has-symbols@1.1.0:
-    resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
-    engines: {node: '>= 0.4'}
-
-  has-tostringtag@1.0.2:
-    resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
-    engines: {node: '>= 0.4'}
-
-  hasown@2.0.2:
-    resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
-    engines: {node: '>= 0.4'}
-
-  hermes-estree@0.25.1:
-    resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==}
-
-  hermes-parser@0.25.1:
-    resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
-
-  ignore@5.3.2:
-    resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
-    engines: {node: '>= 4'}
-
-  ignore@7.0.5:
-    resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
-    engines: {node: '>= 4'}
-
-  import-fresh@3.3.1:
-    resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
-    engines: {node: '>=6'}
-
-  imurmurhash@0.1.4:
-    resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
-    engines: {node: '>=0.8.19'}
-
-  internal-slot@1.1.0:
-    resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
-    engines: {node: '>= 0.4'}
-
-  is-array-buffer@3.0.5:
-    resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
-    engines: {node: '>= 0.4'}
-
-  is-async-function@2.1.1:
-    resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==}
-    engines: {node: '>= 0.4'}
-
-  is-bigint@1.1.0:
-    resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==}
-    engines: {node: '>= 0.4'}
-
-  is-boolean-object@1.2.2:
-    resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==}
-    engines: {node: '>= 0.4'}
-
-  is-bun-module@2.0.0:
-    resolution: {integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==}
-
-  is-callable@1.2.7:
-    resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==}
-    engines: {node: '>= 0.4'}
-
-  is-core-module@2.16.1:
-    resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==}
-    engines: {node: '>= 0.4'}
-
-  is-data-view@1.0.2:
-    resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==}
-    engines: {node: '>= 0.4'}
-
-  is-date-object@1.1.0:
-    resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==}
-    engines: {node: '>= 0.4'}
-
-  is-extglob@2.1.1:
-    resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
-    engines: {node: '>=0.10.0'}
-
-  is-finalizationregistry@1.1.1:
-    resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==}
-    engines: {node: '>= 0.4'}
-
-  is-generator-function@1.1.2:
-    resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==}
-    engines: {node: '>= 0.4'}
-
-  is-glob@4.0.3:
-    resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
-    engines: {node: '>=0.10.0'}
-
-  is-map@2.0.3:
-    resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==}
-    engines: {node: '>= 0.4'}
-
-  is-negative-zero@2.0.3:
-    resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==}
-    engines: {node: '>= 0.4'}
-
-  is-number-object@1.1.1:
-    resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==}
-    engines: {node: '>= 0.4'}
-
-  is-number@7.0.0:
-    resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
-    engines: {node: '>=0.12.0'}
-
-  is-regex@1.2.1:
-    resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==}
-    engines: {node: '>= 0.4'}
-
-  is-set@2.0.3:
-    resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==}
-    engines: {node: '>= 0.4'}
-
-  is-shared-array-buffer@1.0.4:
-    resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==}
-    engines: {node: '>= 0.4'}
-
-  is-string@1.1.1:
-    resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==}
-    engines: {node: '>= 0.4'}
-
-  is-symbol@1.1.1:
-    resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==}
-    engines: {node: '>= 0.4'}
-
-  is-typed-array@1.1.15:
-    resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==}
-    engines: {node: '>= 0.4'}
-
-  is-weakmap@2.0.2:
-    resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==}
-    engines: {node: '>= 0.4'}
-
-  is-weakref@1.1.1:
-    resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==}
-    engines: {node: '>= 0.4'}
-
-  is-weakset@2.0.4:
-    resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==}
-    engines: {node: '>= 0.4'}
-
-  isarray@2.0.5:
-    resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
-
-  isexe@2.0.0:
-    resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
-
-  iterator.prototype@1.1.5:
-    resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==}
-    engines: {node: '>= 0.4'}
-
-  jiti@2.6.1:
-    resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
-    hasBin: true
-
-  jose@6.1.3:
-    resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==}
-
-  js-tokens@4.0.0:
-    resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
-
-  js-yaml@4.1.1:
-    resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
-    hasBin: true
-
-  jsesc@3.1.0:
-    resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
-    engines: {node: '>=6'}
-    hasBin: true
-
-  json-buffer@3.0.1:
-    resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
-
-  json-schema-traverse@0.4.1:
-    resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
-
-  json-stable-stringify-without-jsonify@1.0.1:
-    resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
-
-  json5@1.0.2:
-    resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==}
-    hasBin: true
-
-  json5@2.2.3:
-    resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
-    engines: {node: '>=6'}
-    hasBin: true
-
-  jsx-ast-utils@3.3.5:
-    resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
-    engines: {node: '>=4.0'}
-
-  keyv@4.5.4:
-    resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
-
-  language-subtag-registry@0.3.23:
-    resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==}
-
-  language-tags@1.0.9:
-    resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==}
-    engines: {node: '>=0.10'}
-
-  levn@0.4.1:
-    resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
-    engines: {node: '>= 0.8.0'}
-
-  lightningcss-android-arm64@1.30.2:
-    resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==}
-    engines: {node: '>= 12.0.0'}
-    cpu: [arm64]
-    os: [android]
-
-  lightningcss-darwin-arm64@1.30.2:
-    resolution: {integrity: sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==}
-    engines: {node: '>= 12.0.0'}
-    cpu: [arm64]
-    os: [darwin]
-
-  lightningcss-darwin-x64@1.30.2:
-    resolution: {integrity: sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==}
-    engines: {node: '>= 12.0.0'}
-    cpu: [x64]
-    os: [darwin]
-
-  lightningcss-freebsd-x64@1.30.2:
-    resolution: {integrity: sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==}
-    engines: {node: '>= 12.0.0'}
-    cpu: [x64]
-    os: [freebsd]
-
-  lightningcss-linux-arm-gnueabihf@1.30.2:
-    resolution: {integrity: sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==}
-    engines: {node: '>= 12.0.0'}
-    cpu: [arm]
-    os: [linux]
-
-  lightningcss-linux-arm64-gnu@1.30.2:
-    resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==}
-    engines: {node: '>= 12.0.0'}
-    cpu: [arm64]
-    os: [linux]
-
-  lightningcss-linux-arm64-musl@1.30.2:
-    resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==}
-    engines: {node: '>= 12.0.0'}
-    cpu: [arm64]
-    os: [linux]
-
-  lightningcss-linux-x64-gnu@1.30.2:
-    resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==}
-    engines: {node: '>= 12.0.0'}
-    cpu: [x64]
-    os: [linux]
-
-  lightningcss-linux-x64-musl@1.30.2:
-    resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==}
-    engines: {node: '>= 12.0.0'}
-    cpu: [x64]
-    os: [linux]
-
-  lightningcss-win32-arm64-msvc@1.30.2:
-    resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==}
-    engines: {node: '>= 12.0.0'}
-    cpu: [arm64]
-    os: [win32]
-
-  lightningcss-win32-x64-msvc@1.30.2:
-    resolution: {integrity: sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==}
-    engines: {node: '>= 12.0.0'}
-    cpu: [x64]
-    os: [win32]
-
-  lightningcss@1.30.2:
-    resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==}
-    engines: {node: '>= 12.0.0'}
-
-  locate-path@6.0.0:
-    resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
-    engines: {node: '>=10'}
-
-  lodash.merge@4.6.2:
-    resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
-
-  loose-envify@1.4.0:
-    resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
-    hasBin: true
-
-  lru-cache@5.1.1:
-    resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
-
-  magic-string@0.30.21:
-    resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
-
-  math-intrinsics@1.1.0:
-    resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
-    engines: {node: '>= 0.4'}
-
-  merge2@1.4.1:
-    resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
-    engines: {node: '>= 8'}
-
-  micromatch@4.0.8:
-    resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
-    engines: {node: '>=8.6'}
-
-  mini-svg-data-uri@1.4.4:
-    resolution: {integrity: sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==}
-    hasBin: true
-
-  minimatch@3.1.2:
-    resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
-
-  minimatch@9.0.5:
-    resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==}
-    engines: {node: '>=16 || 14 >=14.17'}
-
-  minimist@1.2.8:
-    resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
-
-  ms@2.1.3:
-    resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
-
-  nanoid@3.3.11:
-    resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
-    engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
-    hasBin: true
-
-  napi-postinstall@0.3.4:
-    resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==}
-    engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
-    hasBin: true
-
-  natural-compare@1.4.0:
-    resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
-
-  next-auth@5.0.0-beta.30:
-    resolution: {integrity: sha512-+c51gquM3F6nMVmoAusRJ7RIoY0K4Ts9HCCwyy/BRoe4mp3msZpOzYMyb5LAYc1wSo74PMQkGDcaghIO7W6Xjg==}
-    peerDependencies:
-      '@simplewebauthn/browser': ^9.0.1
-      '@simplewebauthn/server': ^9.0.2
-      next: ^14.0.0-0 || ^15.0.0 || ^16.0.0
-      nodemailer: ^7.0.7
-      react: ^18.2.0 || ^19.0.0
-    peerDependenciesMeta:
-      '@simplewebauthn/browser':
-        optional: true
-      '@simplewebauthn/server':
-        optional: true
-      nodemailer:
-        optional: true
-
-  next@15.5.9:
-    resolution: {integrity: sha512-agNLK89seZEtC5zUHwtut0+tNrc0Xw4FT/Dg+B/VLEo9pAcS9rtTKpek3V6kVcVwsB2YlqMaHdfZL4eLEVYuCg==}
-    engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
-    hasBin: true
-    peerDependencies:
-      '@opentelemetry/api': ^1.1.0
-      '@playwright/test': ^1.51.1
-      babel-plugin-react-compiler: '*'
-      react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
-      react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
-      sass: ^1.3.0
-    peerDependenciesMeta:
-      '@opentelemetry/api':
-        optional: true
-      '@playwright/test':
-        optional: true
-      babel-plugin-react-compiler:
-        optional: true
-      sass:
-        optional: true
-
-  node-addon-api@8.5.0:
-    resolution: {integrity: sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==}
-    engines: {node: ^18 || ^20 || >= 21}
-
-  node-gyp-build@4.8.4:
-    resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
-    hasBin: true
-
-  node-releases@2.0.27:
-    resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
-
-  oauth4webapi@3.8.3:
-    resolution: {integrity: sha512-pQ5BsX3QRTgnt5HxgHwgunIRaDXBdkT23tf8dfzmtTIL2LTpdmxgbpbBm0VgFWAIDlezQvQCTgnVIUmHupXHxw==}
-
-  object-assign@4.1.1:
-    resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
-    engines: {node: '>=0.10.0'}
-
-  object-inspect@1.13.4:
-    resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
-    engines: {node: '>= 0.4'}
-
-  object-keys@1.1.1:
-    resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==}
-    engines: {node: '>= 0.4'}
-
-  object.assign@4.1.7:
-    resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==}
-    engines: {node: '>= 0.4'}
-
-  object.entries@1.1.9:
-    resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==}
-    engines: {node: '>= 0.4'}
-
-  object.fromentries@2.0.8:
-    resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==}
-    engines: {node: '>= 0.4'}
-
-  object.groupby@1.0.3:
-    resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==}
-    engines: {node: '>= 0.4'}
-
-  object.values@1.2.1:
-    resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
-    engines: {node: '>= 0.4'}
-
-  optionator@0.9.4:
-    resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
-    engines: {node: '>= 0.8.0'}
-
-  own-keys@1.0.1:
-    resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
-    engines: {node: '>= 0.4'}
-
-  p-limit@3.1.0:
-    resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
-    engines: {node: '>=10'}
-
-  p-locate@5.0.0:
-    resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
-    engines: {node: '>=10'}
-
-  parent-module@1.0.1:
-    resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
-    engines: {node: '>=6'}
-
-  path-exists@4.0.0:
-    resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
-    engines: {node: '>=8'}
-
-  path-key@3.1.1:
-    resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
-    engines: {node: '>=8'}
-
-  path-parse@1.0.7:
-    resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
-
-  picocolors@1.1.1:
-    resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
-
-  picomatch@2.3.1:
-    resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
-    engines: {node: '>=8.6'}
-
-  picomatch@4.0.3:
-    resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
-    engines: {node: '>=12'}
-
-  possible-typed-array-names@1.1.0:
-    resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
-    engines: {node: '>= 0.4'}
-
-  postcss-value-parser@4.2.0:
-    resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
-
-  postcss@8.4.31:
-    resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
-    engines: {node: ^10 || ^12 || >=14}
-
-  postcss@8.5.6:
-    resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
-    engines: {node: ^10 || ^12 || >=14}
-
-  postgres@3.4.8:
-    resolution: {integrity: sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg==}
-    engines: {node: '>=12'}
-
-  preact-render-to-string@6.5.11:
-    resolution: {integrity: sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==}
-    peerDependencies:
-      preact: '>=10'
-
-  preact@10.24.3:
-    resolution: {integrity: sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==}
-
-  prelude-ls@1.2.1:
-    resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
-    engines: {node: '>= 0.8.0'}
-
-  prop-types@15.8.1:
-    resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
-
-  punycode@2.3.1:
-    resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
-    engines: {node: '>=6'}
-
-  queue-microtask@1.2.3:
-    resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
-
-  react-dom@19.2.3:
-    resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==}
-    peerDependencies:
-      react: ^19.2.3
-
-  react-is@16.13.1:
-    resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
-
-  react@19.2.3:
-    resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==}
-    engines: {node: '>=0.10.0'}
-
-  reflect.getprototypeof@1.0.10:
-    resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
-    engines: {node: '>= 0.4'}
-
-  regexp.prototype.flags@1.5.4:
-    resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
-    engines: {node: '>= 0.4'}
-
-  resolve-from@4.0.0:
-    resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
-    engines: {node: '>=4'}
-
-  resolve-pkg-maps@1.0.0:
-    resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
-
-  resolve@1.22.11:
-    resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==}
-    engines: {node: '>= 0.4'}
-    hasBin: true
-
-  resolve@2.0.0-next.5:
-    resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==}
-    hasBin: true
-
-  reusify@1.1.0:
-    resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
-    engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
-
-  run-parallel@1.2.0:
-    resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
-
-  safe-array-concat@1.1.3:
-    resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==}
-    engines: {node: '>=0.4'}
-
-  safe-push-apply@1.0.0:
-    resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==}
-    engines: {node: '>= 0.4'}
-
-  safe-regex-test@1.1.0:
-    resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
-    engines: {node: '>= 0.4'}
-
-  scheduler@0.27.0:
-    resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
-
-  semver@6.3.1:
-    resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
-    hasBin: true
-
-  semver@7.7.3:
-    resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==}
-    engines: {node: '>=10'}
-    hasBin: true
-
-  set-function-length@1.2.2:
-    resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
-    engines: {node: '>= 0.4'}
-
-  set-function-name@2.0.2:
-    resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==}
-    engines: {node: '>= 0.4'}
-
-  set-proto@1.0.0:
-    resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==}
-    engines: {node: '>= 0.4'}
-
-  sharp@0.34.5:
-    resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
-    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
-
-  shebang-command@2.0.0:
-    resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
-    engines: {node: '>=8'}
-
-  shebang-regex@3.0.0:
-    resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
-    engines: {node: '>=8'}
-
-  side-channel-list@1.0.0:
-    resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==}
-    engines: {node: '>= 0.4'}
-
-  side-channel-map@1.0.1:
-    resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==}
-    engines: {node: '>= 0.4'}
-
-  side-channel-weakmap@1.0.2:
-    resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==}
-    engines: {node: '>= 0.4'}
-
-  side-channel@1.1.0:
-    resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
-    engines: {node: '>= 0.4'}
-
-  source-map-js@1.2.1:
-    resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
-    engines: {node: '>=0.10.0'}
-
-  stable-hash@0.0.5:
-    resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
-
-  stop-iteration-iterator@1.1.0:
-    resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
-    engines: {node: '>= 0.4'}
-
-  string.prototype.includes@2.0.1:
-    resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==}
-    engines: {node: '>= 0.4'}
-
-  string.prototype.matchall@4.0.12:
-    resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==}
-    engines: {node: '>= 0.4'}
-
-  string.prototype.repeat@1.0.0:
-    resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==}
-
-  string.prototype.trim@1.2.10:
-    resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==}
-    engines: {node: '>= 0.4'}
-
-  string.prototype.trimend@1.0.9:
-    resolution: {integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==}
-    engines: {node: '>= 0.4'}
-
-  string.prototype.trimstart@1.0.8:
-    resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==}
-    engines: {node: '>= 0.4'}
-
-  strip-bom@3.0.0:
-    resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
-    engines: {node: '>=4'}
-
-  strip-json-comments@3.1.1:
-    resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
-    engines: {node: '>=8'}
-
-  styled-jsx@5.1.6:
-    resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
-    engines: {node: '>= 12.0.0'}
-    peerDependencies:
-      '@babel/core': '*'
-      babel-plugin-macros: '*'
-      react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0'
-    peerDependenciesMeta:
-      '@babel/core':
-        optional: true
-      babel-plugin-macros:
-        optional: true
-
-  supports-color@7.2.0:
-    resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
-    engines: {node: '>=8'}
-
-  supports-preserve-symlinks-flag@1.0.0:
-    resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
-    engines: {node: '>= 0.4'}
-
-  tailwindcss@4.1.18:
-    resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==}
-    hasBin: true
-
-  tapable@2.3.0:
-    resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
-    engines: {node: '>=6'}
-
-  tinyglobby@0.2.15:
-    resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
-    engines: {node: '>=12.0.0'}
-
-  to-regex-range@5.0.1:
-    resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
-    engines: {node: '>=8.0'}
-
-  ts-api-utils@2.4.0:
-    resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==}
-    engines: {node: '>=18.12'}
-    peerDependencies:
-      typescript: '>=4.8.4'
-
-  tsconfig-paths@3.15.0:
-    resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==}
-
-  tslib@2.8.1:
-    resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
-
-  type-check@0.4.0:
-    resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
-    engines: {node: '>= 0.8.0'}
-
-  typed-array-buffer@1.0.3:
-    resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
-    engines: {node: '>= 0.4'}
-
-  typed-array-byte-length@1.0.3:
-    resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==}
-    engines: {node: '>= 0.4'}
-
-  typed-array-byte-offset@1.0.4:
-    resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==}
-    engines: {node: '>= 0.4'}
-
-  typed-array-length@1.0.7:
-    resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==}
-    engines: {node: '>= 0.4'}
-
-  typescript-eslint@8.53.1:
-    resolution: {integrity: sha512-gB+EVQfP5RDElh9ittfXlhZJdjSU4jUSTyE2+ia8CYyNvet4ElfaLlAIqDvQV9JPknKx0jQH1racTYe/4LaLSg==}
-    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
-    peerDependencies:
-      eslint: ^8.57.0 || ^9.0.0
-      typescript: '>=4.8.4 <6.0.0'
-
-  typescript@5.9.3:
-    resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
-    engines: {node: '>=14.17'}
-    hasBin: true
-
-  unbox-primitive@1.1.0:
-    resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
-    engines: {node: '>= 0.4'}
-
-  undici-types@7.16.0:
-    resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
-
-  unrs-resolver@1.11.1:
-    resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==}
-
-  update-browserslist-db@1.2.3:
-    resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
-    hasBin: true
-    peerDependencies:
-      browserslist: '>= 4.21.0'
-
-  uri-js@4.4.1:
-    resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
-
-  use-debounce@10.1.0:
-    resolution: {integrity: sha512-lu87Za35V3n/MyMoEpD5zJv0k7hCn0p+V/fK2kWD+3k2u3kOCwO593UArbczg1fhfs2rqPEnHpULJ3KmGdDzvg==}
-    engines: {node: '>= 16.0.0'}
-    peerDependencies:
-      react: '*'
-
-  which-boxed-primitive@1.1.1:
-    resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
-    engines: {node: '>= 0.4'}
-
-  which-builtin-type@1.2.1:
-    resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==}
-    engines: {node: '>= 0.4'}
-
-  which-collection@1.0.2:
-    resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==}
-    engines: {node: '>= 0.4'}
-
-  which-typed-array@1.1.20:
-    resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==}
-    engines: {node: '>= 0.4'}
-
-  which@2.0.2:
-    resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
-    engines: {node: '>= 8'}
-    hasBin: true
-
-  word-wrap@1.2.5:
-    resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
-    engines: {node: '>=0.10.0'}
-
-  yallist@3.1.1:
-    resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
-
-  yocto-queue@0.1.0:
-    resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
-    engines: {node: '>=10'}
-
-  zod-validation-error@4.0.2:
-    resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==}
-    engines: {node: '>=18.0.0'}
-    peerDependencies:
-      zod: ^3.25.0 || ^4.0.0
-
-  zod@4.3.5:
-    resolution: {integrity: sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==}
-
-snapshots:
-
-  '@alloc/quick-lru@5.2.0': {}
-
-  '@auth/core@0.41.0':
-    dependencies:
-      '@panva/hkdf': 1.2.1
-      jose: 6.1.3
-      oauth4webapi: 3.8.3
-      preact: 10.24.3
-      preact-render-to-string: 6.5.11(preact@10.24.3)
-
-  '@babel/code-frame@7.28.6':
-    dependencies:
-      '@babel/helper-validator-identifier': 7.28.5
-      js-tokens: 4.0.0
-      picocolors: 1.1.1
-
-  '@babel/compat-data@7.28.6': {}
-
-  '@babel/core@7.28.6':
-    dependencies:
-      '@babel/code-frame': 7.28.6
-      '@babel/generator': 7.28.6
-      '@babel/helper-compilation-targets': 7.28.6
-      '@babel/helper-module-transforms': 7.28.6(@babel/core@7.28.6)
-      '@babel/helpers': 7.28.6
-      '@babel/parser': 7.28.6
-      '@babel/template': 7.28.6
-      '@babel/traverse': 7.28.6
-      '@babel/types': 7.28.6
-      '@jridgewell/remapping': 2.3.5
-      convert-source-map: 2.0.0
-      debug: 4.4.3
-      gensync: 1.0.0-beta.2
-      json5: 2.2.3
-      semver: 6.3.1
-    transitivePeerDependencies:
-      - supports-color
-
-  '@babel/generator@7.28.6':
-    dependencies:
-      '@babel/parser': 7.28.6
-      '@babel/types': 7.28.6
-      '@jridgewell/gen-mapping': 0.3.13
-      '@jridgewell/trace-mapping': 0.3.31
-      jsesc: 3.1.0
-
-  '@babel/helper-compilation-targets@7.28.6':
-    dependencies:
-      '@babel/compat-data': 7.28.6
-      '@babel/helper-validator-option': 7.27.1
-      browserslist: 4.28.1
-      lru-cache: 5.1.1
-      semver: 6.3.1
-
-  '@babel/helper-globals@7.28.0': {}
-
-  '@babel/helper-module-imports@7.28.6':
-    dependencies:
-      '@babel/traverse': 7.28.6
-      '@babel/types': 7.28.6
-    transitivePeerDependencies:
-      - supports-color
-
-  '@babel/helper-module-transforms@7.28.6(@babel/core@7.28.6)':
-    dependencies:
-      '@babel/core': 7.28.6
-      '@babel/helper-module-imports': 7.28.6
-      '@babel/helper-validator-identifier': 7.28.5
-      '@babel/traverse': 7.28.6
-    transitivePeerDependencies:
-      - supports-color
-
-  '@babel/helper-string-parser@7.27.1': {}
-
-  '@babel/helper-validator-identifier@7.28.5': {}
-
-  '@babel/helper-validator-option@7.27.1': {}
-
-  '@babel/helpers@7.28.6':
-    dependencies:
-      '@babel/template': 7.28.6
-      '@babel/types': 7.28.6
-
-  '@babel/parser@7.28.6':
-    dependencies:
-      '@babel/types': 7.28.6
-
-  '@babel/template@7.28.6':
-    dependencies:
-      '@babel/code-frame': 7.28.6
-      '@babel/parser': 7.28.6
-      '@babel/types': 7.28.6
-
-  '@babel/traverse@7.28.6':
-    dependencies:
-      '@babel/code-frame': 7.28.6
-      '@babel/generator': 7.28.6
-      '@babel/helper-globals': 7.28.0
-      '@babel/parser': 7.28.6
-      '@babel/template': 7.28.6
-      '@babel/types': 7.28.6
-      debug: 4.4.3
-    transitivePeerDependencies:
-      - supports-color
-
-  '@babel/types@7.28.6':
-    dependencies:
-      '@babel/helper-string-parser': 7.27.1
-      '@babel/helper-validator-identifier': 7.28.5
-
-  '@emnapi/core@1.8.1':
-    dependencies:
-      '@emnapi/wasi-threads': 1.1.0
-      tslib: 2.8.1
-    optional: true
-
-  '@emnapi/runtime@1.8.1':
-    dependencies:
-      tslib: 2.8.1
-    optional: true
-
-  '@emnapi/wasi-threads@1.1.0':
-    dependencies:
-      tslib: 2.8.1
-    optional: true
-
-  '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@2.6.1))':
-    dependencies:
-      eslint: 9.39.2(jiti@2.6.1)
-      eslint-visitor-keys: 3.4.3
-
-  '@eslint-community/regexpp@4.12.2': {}
-
-  '@eslint/config-array@0.21.1':
-    dependencies:
-      '@eslint/object-schema': 2.1.7
-      debug: 4.4.3
-      minimatch: 3.1.2
-    transitivePeerDependencies:
-      - supports-color
-
-  '@eslint/config-helpers@0.4.2':
-    dependencies:
-      '@eslint/core': 0.17.0
-
-  '@eslint/core@0.17.0':
-    dependencies:
-      '@types/json-schema': 7.0.15
-
-  '@eslint/eslintrc@3.3.3':
-    dependencies:
-      ajv: 6.12.6
-      debug: 4.4.3
-      espree: 10.4.0
-      globals: 14.0.0
-      ignore: 5.3.2
-      import-fresh: 3.3.1
-      js-yaml: 4.1.1
-      minimatch: 3.1.2
-      strip-json-comments: 3.1.1
-    transitivePeerDependencies:
-      - supports-color
-
-  '@eslint/js@9.39.2': {}
-
-  '@eslint/object-schema@2.1.7': {}
-
-  '@eslint/plugin-kit@0.4.1':
-    dependencies:
-      '@eslint/core': 0.17.0
-      levn: 0.4.1
-
-  '@heroicons/react@2.2.0(react@19.2.3)':
-    dependencies:
-      react: 19.2.3
-
-  '@humanfs/core@0.19.1': {}
-
-  '@humanfs/node@0.16.7':
-    dependencies:
-      '@humanfs/core': 0.19.1
-      '@humanwhocodes/retry': 0.4.3
-
-  '@humanwhocodes/module-importer@1.0.1': {}
-
-  '@humanwhocodes/retry@0.4.3': {}
-
-  '@img/colour@1.0.0':
-    optional: true
-
-  '@img/sharp-darwin-arm64@0.34.5':
-    optionalDependencies:
-      '@img/sharp-libvips-darwin-arm64': 1.2.4
-    optional: true
-
-  '@img/sharp-darwin-x64@0.34.5':
-    optionalDependencies:
-      '@img/sharp-libvips-darwin-x64': 1.2.4
-    optional: true
-
-  '@img/sharp-libvips-darwin-arm64@1.2.4':
-    optional: true
-
-  '@img/sharp-libvips-darwin-x64@1.2.4':
-    optional: true
-
-  '@img/sharp-libvips-linux-arm64@1.2.4':
-    optional: true
-
-  '@img/sharp-libvips-linux-arm@1.2.4':
-    optional: true
-
-  '@img/sharp-libvips-linux-ppc64@1.2.4':
-    optional: true
-
-  '@img/sharp-libvips-linux-riscv64@1.2.4':
-    optional: true
-
-  '@img/sharp-libvips-linux-s390x@1.2.4':
-    optional: true
-
-  '@img/sharp-libvips-linux-x64@1.2.4':
-    optional: true
-
-  '@img/sharp-libvips-linuxmusl-arm64@1.2.4':
-    optional: true
-
-  '@img/sharp-libvips-linuxmusl-x64@1.2.4':
-    optional: true
-
-  '@img/sharp-linux-arm64@0.34.5':
-    optionalDependencies:
-      '@img/sharp-libvips-linux-arm64': 1.2.4
-    optional: true
-
-  '@img/sharp-linux-arm@0.34.5':
-    optionalDependencies:
-      '@img/sharp-libvips-linux-arm': 1.2.4
-    optional: true
-
-  '@img/sharp-linux-ppc64@0.34.5':
-    optionalDependencies:
-      '@img/sharp-libvips-linux-ppc64': 1.2.4
-    optional: true
-
-  '@img/sharp-linux-riscv64@0.34.5':
-    optionalDependencies:
-      '@img/sharp-libvips-linux-riscv64': 1.2.4
-    optional: true
-
-  '@img/sharp-linux-s390x@0.34.5':
-    optionalDependencies:
-      '@img/sharp-libvips-linux-s390x': 1.2.4
-    optional: true
-
-  '@img/sharp-linux-x64@0.34.5':
-    optionalDependencies:
-      '@img/sharp-libvips-linux-x64': 1.2.4
-    optional: true
-
-  '@img/sharp-linuxmusl-arm64@0.34.5':
-    optionalDependencies:
-      '@img/sharp-libvips-linuxmusl-arm64': 1.2.4
-    optional: true
-
-  '@img/sharp-linuxmusl-x64@0.34.5':
-    optionalDependencies:
-      '@img/sharp-libvips-linuxmusl-x64': 1.2.4
-    optional: true
-
-  '@img/sharp-wasm32@0.34.5':
-    dependencies:
-      '@emnapi/runtime': 1.8.1
-    optional: true
-
-  '@img/sharp-win32-arm64@0.34.5':
-    optional: true
-
-  '@img/sharp-win32-ia32@0.34.5':
-    optional: true
-
-  '@img/sharp-win32-x64@0.34.5':
-    optional: true
-
-  '@jridgewell/gen-mapping@0.3.13':
-    dependencies:
-      '@jridgewell/sourcemap-codec': 1.5.5
-      '@jridgewell/trace-mapping': 0.3.31
-
-  '@jridgewell/remapping@2.3.5':
-    dependencies:
-      '@jridgewell/gen-mapping': 0.3.13
-      '@jridgewell/trace-mapping': 0.3.31
-
-  '@jridgewell/resolve-uri@3.1.2': {}
-
-  '@jridgewell/sourcemap-codec@1.5.5': {}
-
-  '@jridgewell/trace-mapping@0.3.31':
-    dependencies:
-      '@jridgewell/resolve-uri': 3.1.2
-      '@jridgewell/sourcemap-codec': 1.5.5
-
-  '@napi-rs/wasm-runtime@0.2.12':
-    dependencies:
-      '@emnapi/core': 1.8.1
-      '@emnapi/runtime': 1.8.1
-      '@tybys/wasm-util': 0.10.1
-    optional: true
-
-  '@next/env@15.5.9': {}
-
-  '@next/eslint-plugin-next@16.1.3':
-    dependencies:
-      fast-glob: 3.3.1
-
-  '@next/swc-darwin-arm64@15.5.7':
-    optional: true
-
-  '@next/swc-darwin-x64@15.5.7':
-    optional: true
-
-  '@next/swc-linux-arm64-gnu@15.5.7':
-    optional: true
-
-  '@next/swc-linux-arm64-musl@15.5.7':
-    optional: true
-
-  '@next/swc-linux-x64-gnu@15.5.7':
-    optional: true
-
-  '@next/swc-linux-x64-musl@15.5.7':
-    optional: true
-
-  '@next/swc-win32-arm64-msvc@15.5.7':
-    optional: true
-
-  '@next/swc-win32-x64-msvc@15.5.7':
-    optional: true
-
-  '@nodelib/fs.scandir@2.1.5':
-    dependencies:
-      '@nodelib/fs.stat': 2.0.5
-      run-parallel: 1.2.0
-
-  '@nodelib/fs.stat@2.0.5': {}
-
-  '@nodelib/fs.walk@1.2.8':
-    dependencies:
-      '@nodelib/fs.scandir': 2.1.5
-      fastq: 1.20.1
-
-  '@nolyfill/is-core-module@1.0.39': {}
-
-  '@panva/hkdf@1.2.1': {}
-
-  '@rtsao/scc@1.1.0': {}
-
-  '@swc/helpers@0.5.15':
-    dependencies:
-      tslib: 2.8.1
-
-  '@tailwindcss/forms@0.5.11(tailwindcss@4.1.18)':
-    dependencies:
-      mini-svg-data-uri: 1.4.4
-      tailwindcss: 4.1.18
-
-  '@tailwindcss/node@4.1.18':
-    dependencies:
-      '@jridgewell/remapping': 2.3.5
-      enhanced-resolve: 5.18.4
-      jiti: 2.6.1
-      lightningcss: 1.30.2
-      magic-string: 0.30.21
-      source-map-js: 1.2.1
-      tailwindcss: 4.1.18
-
-  '@tailwindcss/oxide-android-arm64@4.1.18':
-    optional: true
-
-  '@tailwindcss/oxide-darwin-arm64@4.1.18':
-    optional: true
-
-  '@tailwindcss/oxide-darwin-x64@4.1.18':
-    optional: true
-
-  '@tailwindcss/oxide-freebsd-x64@4.1.18':
-    optional: true
-
-  '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18':
-    optional: true
-
-  '@tailwindcss/oxide-linux-arm64-gnu@4.1.18':
-    optional: true
-
-  '@tailwindcss/oxide-linux-arm64-musl@4.1.18':
-    optional: true
-
-  '@tailwindcss/oxide-linux-x64-gnu@4.1.18':
-    optional: true
-
-  '@tailwindcss/oxide-linux-x64-musl@4.1.18':
-    optional: true
-
-  '@tailwindcss/oxide-wasm32-wasi@4.1.18':
-    optional: true
-
-  '@tailwindcss/oxide-win32-arm64-msvc@4.1.18':
-    optional: true
-
-  '@tailwindcss/oxide-win32-x64-msvc@4.1.18':
-    optional: true
-
-  '@tailwindcss/oxide@4.1.18':
-    optionalDependencies:
-      '@tailwindcss/oxide-android-arm64': 4.1.18
-      '@tailwindcss/oxide-darwin-arm64': 4.1.18
-      '@tailwindcss/oxide-darwin-x64': 4.1.18
-      '@tailwindcss/oxide-freebsd-x64': 4.1.18
-      '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.18
-      '@tailwindcss/oxide-linux-arm64-gnu': 4.1.18
-      '@tailwindcss/oxide-linux-arm64-musl': 4.1.18
-      '@tailwindcss/oxide-linux-x64-gnu': 4.1.18
-      '@tailwindcss/oxide-linux-x64-musl': 4.1.18
-      '@tailwindcss/oxide-wasm32-wasi': 4.1.18
-      '@tailwindcss/oxide-win32-arm64-msvc': 4.1.18
-      '@tailwindcss/oxide-win32-x64-msvc': 4.1.18
-
-  '@tailwindcss/postcss@4.1.18':
-    dependencies:
-      '@alloc/quick-lru': 5.2.0
-      '@tailwindcss/node': 4.1.18
-      '@tailwindcss/oxide': 4.1.18
-      postcss: 8.5.6
-      tailwindcss: 4.1.18
-
-  '@tybys/wasm-util@0.10.1':
-    dependencies:
-      tslib: 2.8.1
-    optional: true
-
-  '@types/bcrypt@6.0.0':
-    dependencies:
-      '@types/node': 25.0.9
-
-  '@types/estree@1.0.8': {}
-
-  '@types/json-schema@7.0.15': {}
-
-  '@types/json5@0.0.29': {}
-
-  '@types/node@25.0.9':
-    dependencies:
-      undici-types: 7.16.0
-
-  '@types/react-dom@19.2.3(@types/react@19.2.8)':
-    dependencies:
-      '@types/react': 19.2.8
-
-  '@types/react@19.2.8':
-    dependencies:
-      csstype: 3.2.3
-
-  '@typescript-eslint/eslint-plugin@8.53.1(@typescript-eslint/parser@8.53.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
-    dependencies:
-      '@eslint-community/regexpp': 4.12.2
-      '@typescript-eslint/parser': 8.53.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
-      '@typescript-eslint/scope-manager': 8.53.1
-      '@typescript-eslint/type-utils': 8.53.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
-      '@typescript-eslint/utils': 8.53.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
-      '@typescript-eslint/visitor-keys': 8.53.1
-      eslint: 9.39.2(jiti@2.6.1)
-      ignore: 7.0.5
-      natural-compare: 1.4.0
-      ts-api-utils: 2.4.0(typescript@5.9.3)
-      typescript: 5.9.3
-    transitivePeerDependencies:
-      - supports-color
-
-  '@typescript-eslint/parser@8.53.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
-    dependencies:
-      '@typescript-eslint/scope-manager': 8.53.1
-      '@typescript-eslint/types': 8.53.1
-      '@typescript-eslint/typescript-estree': 8.53.1(typescript@5.9.3)
-      '@typescript-eslint/visitor-keys': 8.53.1
-      debug: 4.4.3
-      eslint: 9.39.2(jiti@2.6.1)
-      typescript: 5.9.3
-    transitivePeerDependencies:
-      - supports-color
-
-  '@typescript-eslint/project-service@8.53.1(typescript@5.9.3)':
-    dependencies:
-      '@typescript-eslint/tsconfig-utils': 8.53.1(typescript@5.9.3)
-      '@typescript-eslint/types': 8.53.1
-      debug: 4.4.3
-      typescript: 5.9.3
-    transitivePeerDependencies:
-      - supports-color
-
-  '@typescript-eslint/scope-manager@8.53.1':
-    dependencies:
-      '@typescript-eslint/types': 8.53.1
-      '@typescript-eslint/visitor-keys': 8.53.1
-
-  '@typescript-eslint/tsconfig-utils@8.53.1(typescript@5.9.3)':
-    dependencies:
-      typescript: 5.9.3
-
-  '@typescript-eslint/type-utils@8.53.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
-    dependencies:
-      '@typescript-eslint/types': 8.53.1
-      '@typescript-eslint/typescript-estree': 8.53.1(typescript@5.9.3)
-      '@typescript-eslint/utils': 8.53.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
-      debug: 4.4.3
-      eslint: 9.39.2(jiti@2.6.1)
-      ts-api-utils: 2.4.0(typescript@5.9.3)
-      typescript: 5.9.3
-    transitivePeerDependencies:
-      - supports-color
-
-  '@typescript-eslint/types@8.53.1': {}
-
-  '@typescript-eslint/typescript-estree@8.53.1(typescript@5.9.3)':
-    dependencies:
-      '@typescript-eslint/project-service': 8.53.1(typescript@5.9.3)
-      '@typescript-eslint/tsconfig-utils': 8.53.1(typescript@5.9.3)
-      '@typescript-eslint/types': 8.53.1
-      '@typescript-eslint/visitor-keys': 8.53.1
-      debug: 4.4.3
-      minimatch: 9.0.5
-      semver: 7.7.3
-      tinyglobby: 0.2.15
-      ts-api-utils: 2.4.0(typescript@5.9.3)
-      typescript: 5.9.3
-    transitivePeerDependencies:
-      - supports-color
-
-  '@typescript-eslint/utils@8.53.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
-    dependencies:
-      '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1))
-      '@typescript-eslint/scope-manager': 8.53.1
-      '@typescript-eslint/types': 8.53.1
-      '@typescript-eslint/typescript-estree': 8.53.1(typescript@5.9.3)
-      eslint: 9.39.2(jiti@2.6.1)
-      typescript: 5.9.3
-    transitivePeerDependencies:
-      - supports-color
-
-  '@typescript-eslint/visitor-keys@8.53.1':
-    dependencies:
-      '@typescript-eslint/types': 8.53.1
-      eslint-visitor-keys: 4.2.1
-
-  '@unrs/resolver-binding-android-arm-eabi@1.11.1':
-    optional: true
-
-  '@unrs/resolver-binding-android-arm64@1.11.1':
-    optional: true
-
-  '@unrs/resolver-binding-darwin-arm64@1.11.1':
-    optional: true
-
-  '@unrs/resolver-binding-darwin-x64@1.11.1':
-    optional: true
-
-  '@unrs/resolver-binding-freebsd-x64@1.11.1':
-    optional: true
-
-  '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1':
-    optional: true
-
-  '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1':
-    optional: true
-
-  '@unrs/resolver-binding-linux-arm64-gnu@1.11.1':
-    optional: true
-
-  '@unrs/resolver-binding-linux-arm64-musl@1.11.1':
-    optional: true
-
-  '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
-    optional: true
-
-  '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
-    optional: true
-
-  '@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
-    optional: true
-
-  '@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
-    optional: true
-
-  '@unrs/resolver-binding-linux-x64-gnu@1.11.1':
-    optional: true
-
-  '@unrs/resolver-binding-linux-x64-musl@1.11.1':
-    optional: true
-
-  '@unrs/resolver-binding-wasm32-wasi@1.11.1':
-    dependencies:
-      '@napi-rs/wasm-runtime': 0.2.12
-    optional: true
-
-  '@unrs/resolver-binding-win32-arm64-msvc@1.11.1':
-    optional: true
-
-  '@unrs/resolver-binding-win32-ia32-msvc@1.11.1':
-    optional: true
-
-  '@unrs/resolver-binding-win32-x64-msvc@1.11.1':
-    optional: true
-
-  acorn-jsx@5.3.2(acorn@8.15.0):
-    dependencies:
-      acorn: 8.15.0
-
-  acorn@8.15.0: {}
-
-  ajv@6.12.6:
-    dependencies:
-      fast-deep-equal: 3.1.3
-      fast-json-stable-stringify: 2.1.0
-      json-schema-traverse: 0.4.1
-      uri-js: 4.4.1
-
-  ansi-styles@4.3.0:
-    dependencies:
-      color-convert: 2.0.1
-
-  argparse@2.0.1: {}
-
-  aria-query@5.3.2: {}
-
-  array-buffer-byte-length@1.0.2:
-    dependencies:
-      call-bound: 1.0.4
-      is-array-buffer: 3.0.5
-
-  array-includes@3.1.9:
-    dependencies:
-      call-bind: 1.0.8
-      call-bound: 1.0.4
-      define-properties: 1.2.1
-      es-abstract: 1.24.1
-      es-object-atoms: 1.1.1
-      get-intrinsic: 1.3.0
-      is-string: 1.1.1
-      math-intrinsics: 1.1.0
-
-  array.prototype.findlast@1.2.5:
-    dependencies:
-      call-bind: 1.0.8
-      define-properties: 1.2.1
-      es-abstract: 1.24.1
-      es-errors: 1.3.0
-      es-object-atoms: 1.1.1
-      es-shim-unscopables: 1.1.0
-
-  array.prototype.findlastindex@1.2.6:
-    dependencies:
-      call-bind: 1.0.8
-      call-bound: 1.0.4
-      define-properties: 1.2.1
-      es-abstract: 1.24.1
-      es-errors: 1.3.0
-      es-object-atoms: 1.1.1
-      es-shim-unscopables: 1.1.0
-
-  array.prototype.flat@1.3.3:
-    dependencies:
-      call-bind: 1.0.8
-      define-properties: 1.2.1
-      es-abstract: 1.24.1
-      es-shim-unscopables: 1.1.0
-
-  array.prototype.flatmap@1.3.3:
-    dependencies:
-      call-bind: 1.0.8
-      define-properties: 1.2.1
-      es-abstract: 1.24.1
-      es-shim-unscopables: 1.1.0
-
-  array.prototype.tosorted@1.1.4:
-    dependencies:
-      call-bind: 1.0.8
-      define-properties: 1.2.1
-      es-abstract: 1.24.1
-      es-errors: 1.3.0
-      es-shim-unscopables: 1.1.0
-
-  arraybuffer.prototype.slice@1.0.4:
-    dependencies:
-      array-buffer-byte-length: 1.0.2
-      call-bind: 1.0.8
-      define-properties: 1.2.1
-      es-abstract: 1.24.1
-      es-errors: 1.3.0
-      get-intrinsic: 1.3.0
-      is-array-buffer: 3.0.5
-
-  ast-types-flow@0.0.8: {}
-
-  async-function@1.0.0: {}
-
-  autoprefixer@10.4.23(postcss@8.5.6):
-    dependencies:
-      browserslist: 4.28.1
-      caniuse-lite: 1.0.30001765
-      fraction.js: 5.3.4
-      picocolors: 1.1.1
-      postcss: 8.5.6
-      postcss-value-parser: 4.2.0
-
-  available-typed-arrays@1.0.7:
-    dependencies:
-      possible-typed-array-names: 1.1.0
-
-  axe-core@4.11.1: {}
-
-  axobject-query@4.1.0: {}
-
-  balanced-match@1.0.2: {}
-
-  baseline-browser-mapping@2.9.15: {}
-
-  bcrypt@6.0.0:
-    dependencies:
-      node-addon-api: 8.5.0
-      node-gyp-build: 4.8.4
-
-  bcryptjs@3.0.3: {}
-
-  brace-expansion@1.1.12:
-    dependencies:
-      balanced-match: 1.0.2
-      concat-map: 0.0.1
-
-  brace-expansion@2.0.2:
-    dependencies:
-      balanced-match: 1.0.2
-
-  braces@3.0.3:
-    dependencies:
-      fill-range: 7.1.1
-
-  browserslist@4.28.1:
-    dependencies:
-      baseline-browser-mapping: 2.9.15
-      caniuse-lite: 1.0.30001765
-      electron-to-chromium: 1.5.267
-      node-releases: 2.0.27
-      update-browserslist-db: 1.2.3(browserslist@4.28.1)
-
-  call-bind-apply-helpers@1.0.2:
-    dependencies:
-      es-errors: 1.3.0
-      function-bind: 1.1.2
-
-  call-bind@1.0.8:
-    dependencies:
-      call-bind-apply-helpers: 1.0.2
-      es-define-property: 1.0.1
-      get-intrinsic: 1.3.0
-      set-function-length: 1.2.2
-
-  call-bound@1.0.4:
-    dependencies:
-      call-bind-apply-helpers: 1.0.2
-      get-intrinsic: 1.3.0
-
-  callsites@3.1.0: {}
-
-  caniuse-lite@1.0.30001765: {}
-
-  chalk@4.1.2:
-    dependencies:
-      ansi-styles: 4.3.0
-      supports-color: 7.2.0
-
-  client-only@0.0.1: {}
-
-  clsx@2.1.1: {}
-
-  color-convert@2.0.1:
-    dependencies:
-      color-name: 1.1.4
-
-  color-name@1.1.4: {}
-
-  concat-map@0.0.1: {}
-
-  convert-source-map@2.0.0: {}
-
-  cross-spawn@7.0.6:
-    dependencies:
-      path-key: 3.1.1
-      shebang-command: 2.0.0
-      which: 2.0.2
-
-  csstype@3.2.3: {}
-
-  damerau-levenshtein@1.0.8: {}
-
-  data-view-buffer@1.0.2:
-    dependencies:
-      call-bound: 1.0.4
-      es-errors: 1.3.0
-      is-data-view: 1.0.2
-
-  data-view-byte-length@1.0.2:
-    dependencies:
-      call-bound: 1.0.4
-      es-errors: 1.3.0
-      is-data-view: 1.0.2
-
-  data-view-byte-offset@1.0.1:
-    dependencies:
-      call-bound: 1.0.4
-      es-errors: 1.3.0
-      is-data-view: 1.0.2
-
-  debug@3.2.7:
-    dependencies:
-      ms: 2.1.3
-
-  debug@4.4.3:
-    dependencies:
-      ms: 2.1.3
-
-  deep-is@0.1.4: {}
-
-  define-data-property@1.1.4:
-    dependencies:
-      es-define-property: 1.0.1
-      es-errors: 1.3.0
-      gopd: 1.2.0
-
-  define-properties@1.2.1:
-    dependencies:
-      define-data-property: 1.1.4
-      has-property-descriptors: 1.0.2
-      object-keys: 1.1.1
-
-  detect-libc@2.1.2: {}
-
-  doctrine@2.1.0:
-    dependencies:
-      esutils: 2.0.3
-
-  dunder-proto@1.0.1:
-    dependencies:
-      call-bind-apply-helpers: 1.0.2
-      es-errors: 1.3.0
-      gopd: 1.2.0
-
-  electron-to-chromium@1.5.267: {}
-
-  emoji-regex@9.2.2: {}
-
-  enhanced-resolve@5.18.4:
-    dependencies:
-      graceful-fs: 4.2.11
-      tapable: 2.3.0
-
-  es-abstract@1.24.1:
-    dependencies:
-      array-buffer-byte-length: 1.0.2
-      arraybuffer.prototype.slice: 1.0.4
-      available-typed-arrays: 1.0.7
-      call-bind: 1.0.8
-      call-bound: 1.0.4
-      data-view-buffer: 1.0.2
-      data-view-byte-length: 1.0.2
-      data-view-byte-offset: 1.0.1
-      es-define-property: 1.0.1
-      es-errors: 1.3.0
-      es-object-atoms: 1.1.1
-      es-set-tostringtag: 2.1.0
-      es-to-primitive: 1.3.0
-      function.prototype.name: 1.1.8
-      get-intrinsic: 1.3.0
-      get-proto: 1.0.1
-      get-symbol-description: 1.1.0
-      globalthis: 1.0.4
-      gopd: 1.2.0
-      has-property-descriptors: 1.0.2
-      has-proto: 1.2.0
-      has-symbols: 1.1.0
-      hasown: 2.0.2
-      internal-slot: 1.1.0
-      is-array-buffer: 3.0.5
-      is-callable: 1.2.7
-      is-data-view: 1.0.2
-      is-negative-zero: 2.0.3
-      is-regex: 1.2.1
-      is-set: 2.0.3
-      is-shared-array-buffer: 1.0.4
-      is-string: 1.1.1
-      is-typed-array: 1.1.15
-      is-weakref: 1.1.1
-      math-intrinsics: 1.1.0
-      object-inspect: 1.13.4
-      object-keys: 1.1.1
-      object.assign: 4.1.7
-      own-keys: 1.0.1
-      regexp.prototype.flags: 1.5.4
-      safe-array-concat: 1.1.3
-      safe-push-apply: 1.0.0
-      safe-regex-test: 1.1.0
-      set-proto: 1.0.0
-      stop-iteration-iterator: 1.1.0
-      string.prototype.trim: 1.2.10
-      string.prototype.trimend: 1.0.9
-      string.prototype.trimstart: 1.0.8
-      typed-array-buffer: 1.0.3
-      typed-array-byte-length: 1.0.3
-      typed-array-byte-offset: 1.0.4
-      typed-array-length: 1.0.7
-      unbox-primitive: 1.1.0
-      which-typed-array: 1.1.20
-
-  es-define-property@1.0.1: {}
-
-  es-errors@1.3.0: {}
-
-  es-iterator-helpers@1.2.2:
-    dependencies:
-      call-bind: 1.0.8
-      call-bound: 1.0.4
-      define-properties: 1.2.1
-      es-abstract: 1.24.1
-      es-errors: 1.3.0
-      es-set-tostringtag: 2.1.0
-      function-bind: 1.1.2
-      get-intrinsic: 1.3.0
-      globalthis: 1.0.4
-      gopd: 1.2.0
-      has-property-descriptors: 1.0.2
-      has-proto: 1.2.0
-      has-symbols: 1.1.0
-      internal-slot: 1.1.0
-      iterator.prototype: 1.1.5
-      safe-array-concat: 1.1.3
-
-  es-object-atoms@1.1.1:
-    dependencies:
-      es-errors: 1.3.0
-
-  es-set-tostringtag@2.1.0:
-    dependencies:
-      es-errors: 1.3.0
-      get-intrinsic: 1.3.0
-      has-tostringtag: 1.0.2
-      hasown: 2.0.2
-
-  es-shim-unscopables@1.1.0:
-    dependencies:
-      hasown: 2.0.2
-
-  es-to-primitive@1.3.0:
-    dependencies:
-      is-callable: 1.2.7
-      is-date-object: 1.1.0
-      is-symbol: 1.1.1
-
-  escalade@3.2.0: {}
-
-  escape-string-regexp@4.0.0: {}
-
-  eslint-config-next@16.1.3(@typescript-eslint/parser@8.53.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3):
-    dependencies:
-      '@next/eslint-plugin-next': 16.1.3
-      eslint: 9.39.2(jiti@2.6.1)
-      eslint-import-resolver-node: 0.3.9
-      eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1))
-      eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1))
-      eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@2.6.1))
-      eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@2.6.1))
-      eslint-plugin-react-hooks: 7.0.1(eslint@9.39.2(jiti@2.6.1))
-      globals: 16.4.0
-      typescript-eslint: 8.53.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
-    optionalDependencies:
-      typescript: 5.9.3
-    transitivePeerDependencies:
-      - '@typescript-eslint/parser'
-      - eslint-import-resolver-webpack
-      - eslint-plugin-import-x
-      - supports-color
-
-  eslint-import-resolver-node@0.3.9:
-    dependencies:
-      debug: 3.2.7
-      is-core-module: 2.16.1
-      resolve: 1.22.11
-    transitivePeerDependencies:
-      - supports-color
-
-  eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)):
-    dependencies:
-      '@nolyfill/is-core-module': 1.0.39
-      debug: 4.4.3
-      eslint: 9.39.2(jiti@2.6.1)
-      get-tsconfig: 4.13.0
-      is-bun-module: 2.0.0
-      stable-hash: 0.0.5
-      tinyglobby: 0.2.15
-      unrs-resolver: 1.11.1
-    optionalDependencies:
-      eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1))
-    transitivePeerDependencies:
-      - supports-color
-
-  eslint-module-utils@2.12.1(@typescript-eslint/parser@8.53.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)):
-    dependencies:
-      debug: 3.2.7
-    optionalDependencies:
-      '@typescript-eslint/parser': 8.53.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
-      eslint: 9.39.2(jiti@2.6.1)
-      eslint-import-resolver-node: 0.3.9
-      eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1))
-    transitivePeerDependencies:
-      - supports-color
-
-  eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)):
-    dependencies:
-      '@rtsao/scc': 1.1.0
-      array-includes: 3.1.9
-      array.prototype.findlastindex: 1.2.6
-      array.prototype.flat: 1.3.3
-      array.prototype.flatmap: 1.3.3
-      debug: 3.2.7
-      doctrine: 2.1.0
-      eslint: 9.39.2(jiti@2.6.1)
-      eslint-import-resolver-node: 0.3.9
-      eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.53.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1))
-      hasown: 2.0.2
-      is-core-module: 2.16.1
-      is-glob: 4.0.3
-      minimatch: 3.1.2
-      object.fromentries: 2.0.8
-      object.groupby: 1.0.3
-      object.values: 1.2.1
-      semver: 6.3.1
-      string.prototype.trimend: 1.0.9
-      tsconfig-paths: 3.15.0
-    optionalDependencies:
-      '@typescript-eslint/parser': 8.53.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
-    transitivePeerDependencies:
-      - eslint-import-resolver-typescript
-      - eslint-import-resolver-webpack
-      - supports-color
-
-  eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.2(jiti@2.6.1)):
-    dependencies:
-      aria-query: 5.3.2
-      array-includes: 3.1.9
-      array.prototype.flatmap: 1.3.3
-      ast-types-flow: 0.0.8
-      axe-core: 4.11.1
-      axobject-query: 4.1.0
-      damerau-levenshtein: 1.0.8
-      emoji-regex: 9.2.2
-      eslint: 9.39.2(jiti@2.6.1)
-      hasown: 2.0.2
-      jsx-ast-utils: 3.3.5
-      language-tags: 1.0.9
-      minimatch: 3.1.2
-      object.fromentries: 2.0.8
-      safe-regex-test: 1.1.0
-      string.prototype.includes: 2.0.1
-
-  eslint-plugin-react-hooks@7.0.1(eslint@9.39.2(jiti@2.6.1)):
-    dependencies:
-      '@babel/core': 7.28.6
-      '@babel/parser': 7.28.6
-      eslint: 9.39.2(jiti@2.6.1)
-      hermes-parser: 0.25.1
-      zod: 4.3.5
-      zod-validation-error: 4.0.2(zod@4.3.5)
-    transitivePeerDependencies:
-      - supports-color
-
-  eslint-plugin-react@7.37.5(eslint@9.39.2(jiti@2.6.1)):
-    dependencies:
-      array-includes: 3.1.9
-      array.prototype.findlast: 1.2.5
-      array.prototype.flatmap: 1.3.3
-      array.prototype.tosorted: 1.1.4
-      doctrine: 2.1.0
-      es-iterator-helpers: 1.2.2
-      eslint: 9.39.2(jiti@2.6.1)
-      estraverse: 5.3.0
-      hasown: 2.0.2
-      jsx-ast-utils: 3.3.5
-      minimatch: 3.1.2
-      object.entries: 1.1.9
-      object.fromentries: 2.0.8
-      object.values: 1.2.1
-      prop-types: 15.8.1
-      resolve: 2.0.0-next.5
-      semver: 6.3.1
-      string.prototype.matchall: 4.0.12
-      string.prototype.repeat: 1.0.0
-
-  eslint-scope@8.4.0:
-    dependencies:
-      esrecurse: 4.3.0
-      estraverse: 5.3.0
-
-  eslint-visitor-keys@3.4.3: {}
-
-  eslint-visitor-keys@4.2.1: {}
-
-  eslint@9.39.2(jiti@2.6.1):
-    dependencies:
-      '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1))
-      '@eslint-community/regexpp': 4.12.2
-      '@eslint/config-array': 0.21.1
-      '@eslint/config-helpers': 0.4.2
-      '@eslint/core': 0.17.0
-      '@eslint/eslintrc': 3.3.3
-      '@eslint/js': 9.39.2
-      '@eslint/plugin-kit': 0.4.1
-      '@humanfs/node': 0.16.7
-      '@humanwhocodes/module-importer': 1.0.1
-      '@humanwhocodes/retry': 0.4.3
-      '@types/estree': 1.0.8
-      ajv: 6.12.6
-      chalk: 4.1.2
-      cross-spawn: 7.0.6
-      debug: 4.4.3
-      escape-string-regexp: 4.0.0
-      eslint-scope: 8.4.0
-      eslint-visitor-keys: 4.2.1
-      espree: 10.4.0
-      esquery: 1.7.0
-      esutils: 2.0.3
-      fast-deep-equal: 3.1.3
-      file-entry-cache: 8.0.0
-      find-up: 5.0.0
-      glob-parent: 6.0.2
-      ignore: 5.3.2
-      imurmurhash: 0.1.4
-      is-glob: 4.0.3
-      json-stable-stringify-without-jsonify: 1.0.1
-      lodash.merge: 4.6.2
-      minimatch: 3.1.2
-      natural-compare: 1.4.0
-      optionator: 0.9.4
-    optionalDependencies:
-      jiti: 2.6.1
-    transitivePeerDependencies:
-      - supports-color
-
-  espree@10.4.0:
-    dependencies:
-      acorn: 8.15.0
-      acorn-jsx: 5.3.2(acorn@8.15.0)
-      eslint-visitor-keys: 4.2.1
-
-  esquery@1.7.0:
-    dependencies:
-      estraverse: 5.3.0
-
-  esrecurse@4.3.0:
-    dependencies:
-      estraverse: 5.3.0
-
-  estraverse@5.3.0: {}
-
-  esutils@2.0.3: {}
-
-  fast-deep-equal@3.1.3: {}
-
-  fast-glob@3.3.1:
-    dependencies:
-      '@nodelib/fs.stat': 2.0.5
-      '@nodelib/fs.walk': 1.2.8
-      glob-parent: 5.1.2
-      merge2: 1.4.1
-      micromatch: 4.0.8
-
-  fast-json-stable-stringify@2.1.0: {}
-
-  fast-levenshtein@2.0.6: {}
-
-  fastq@1.20.1:
-    dependencies:
-      reusify: 1.1.0
-
-  fdir@6.5.0(picomatch@4.0.3):
-    optionalDependencies:
-      picomatch: 4.0.3
-
-  file-entry-cache@8.0.0:
-    dependencies:
-      flat-cache: 4.0.1
-
-  fill-range@7.1.1:
-    dependencies:
-      to-regex-range: 5.0.1
-
-  find-up@5.0.0:
-    dependencies:
-      locate-path: 6.0.0
-      path-exists: 4.0.0
-
-  flat-cache@4.0.1:
-    dependencies:
-      flatted: 3.3.3
-      keyv: 4.5.4
-
-  flatted@3.3.3: {}
-
-  for-each@0.3.5:
-    dependencies:
-      is-callable: 1.2.7
-
-  fraction.js@5.3.4: {}
-
-  function-bind@1.1.2: {}
-
-  function.prototype.name@1.1.8:
-    dependencies:
-      call-bind: 1.0.8
-      call-bound: 1.0.4
-      define-properties: 1.2.1
-      functions-have-names: 1.2.3
-      hasown: 2.0.2
-      is-callable: 1.2.7
-
-  functions-have-names@1.2.3: {}
-
-  generator-function@2.0.1: {}
-
-  gensync@1.0.0-beta.2: {}
-
-  get-intrinsic@1.3.0:
-    dependencies:
-      call-bind-apply-helpers: 1.0.2
-      es-define-property: 1.0.1
-      es-errors: 1.3.0
-      es-object-atoms: 1.1.1
-      function-bind: 1.1.2
-      get-proto: 1.0.1
-      gopd: 1.2.0
-      has-symbols: 1.1.0
-      hasown: 2.0.2
-      math-intrinsics: 1.1.0
-
-  get-proto@1.0.1:
-    dependencies:
-      dunder-proto: 1.0.1
-      es-object-atoms: 1.1.1
-
-  get-symbol-description@1.1.0:
-    dependencies:
-      call-bound: 1.0.4
-      es-errors: 1.3.0
-      get-intrinsic: 1.3.0
-
-  get-tsconfig@4.13.0:
-    dependencies:
-      resolve-pkg-maps: 1.0.0
-
-  glob-parent@5.1.2:
-    dependencies:
-      is-glob: 4.0.3
-
-  glob-parent@6.0.2:
-    dependencies:
-      is-glob: 4.0.3
-
-  globals@14.0.0: {}
-
-  globals@16.4.0: {}
-
-  globalthis@1.0.4:
-    dependencies:
-      define-properties: 1.2.1
-      gopd: 1.2.0
-
-  gopd@1.2.0: {}
-
-  graceful-fs@4.2.11: {}
-
-  has-bigints@1.1.0: {}
-
-  has-flag@4.0.0: {}
-
-  has-property-descriptors@1.0.2:
-    dependencies:
-      es-define-property: 1.0.1
-
-  has-proto@1.2.0:
-    dependencies:
-      dunder-proto: 1.0.1
-
-  has-symbols@1.1.0: {}
-
-  has-tostringtag@1.0.2:
-    dependencies:
-      has-symbols: 1.1.0
-
-  hasown@2.0.2:
-    dependencies:
-      function-bind: 1.1.2
-
-  hermes-estree@0.25.1: {}
-
-  hermes-parser@0.25.1:
-    dependencies:
-      hermes-estree: 0.25.1
-
-  ignore@5.3.2: {}
-
-  ignore@7.0.5: {}
-
-  import-fresh@3.3.1:
-    dependencies:
-      parent-module: 1.0.1
-      resolve-from: 4.0.0
-
-  imurmurhash@0.1.4: {}
-
-  internal-slot@1.1.0:
-    dependencies:
-      es-errors: 1.3.0
-      hasown: 2.0.2
-      side-channel: 1.1.0
-
-  is-array-buffer@3.0.5:
-    dependencies:
-      call-bind: 1.0.8
-      call-bound: 1.0.4
-      get-intrinsic: 1.3.0
-
-  is-async-function@2.1.1:
-    dependencies:
-      async-function: 1.0.0
-      call-bound: 1.0.4
-      get-proto: 1.0.1
-      has-tostringtag: 1.0.2
-      safe-regex-test: 1.1.0
-
-  is-bigint@1.1.0:
-    dependencies:
-      has-bigints: 1.1.0
-
-  is-boolean-object@1.2.2:
-    dependencies:
-      call-bound: 1.0.4
-      has-tostringtag: 1.0.2
-
-  is-bun-module@2.0.0:
-    dependencies:
-      semver: 7.7.3
-
-  is-callable@1.2.7: {}
-
-  is-core-module@2.16.1:
-    dependencies:
-      hasown: 2.0.2
-
-  is-data-view@1.0.2:
-    dependencies:
-      call-bound: 1.0.4
-      get-intrinsic: 1.3.0
-      is-typed-array: 1.1.15
-
-  is-date-object@1.1.0:
-    dependencies:
-      call-bound: 1.0.4
-      has-tostringtag: 1.0.2
-
-  is-extglob@2.1.1: {}
-
-  is-finalizationregistry@1.1.1:
-    dependencies:
-      call-bound: 1.0.4
-
-  is-generator-function@1.1.2:
-    dependencies:
-      call-bound: 1.0.4
-      generator-function: 2.0.1
-      get-proto: 1.0.1
-      has-tostringtag: 1.0.2
-      safe-regex-test: 1.1.0
-
-  is-glob@4.0.3:
-    dependencies:
-      is-extglob: 2.1.1
-
-  is-map@2.0.3: {}
-
-  is-negative-zero@2.0.3: {}
-
-  is-number-object@1.1.1:
-    dependencies:
-      call-bound: 1.0.4
-      has-tostringtag: 1.0.2
-
-  is-number@7.0.0: {}
-
-  is-regex@1.2.1:
-    dependencies:
-      call-bound: 1.0.4
-      gopd: 1.2.0
-      has-tostringtag: 1.0.2
-      hasown: 2.0.2
-
-  is-set@2.0.3: {}
-
-  is-shared-array-buffer@1.0.4:
-    dependencies:
-      call-bound: 1.0.4
-
-  is-string@1.1.1:
-    dependencies:
-      call-bound: 1.0.4
-      has-tostringtag: 1.0.2
-
-  is-symbol@1.1.1:
-    dependencies:
-      call-bound: 1.0.4
-      has-symbols: 1.1.0
-      safe-regex-test: 1.1.0
-
-  is-typed-array@1.1.15:
-    dependencies:
-      which-typed-array: 1.1.20
-
-  is-weakmap@2.0.2: {}
-
-  is-weakref@1.1.1:
-    dependencies:
-      call-bound: 1.0.4
-
-  is-weakset@2.0.4:
-    dependencies:
-      call-bound: 1.0.4
-      get-intrinsic: 1.3.0
-
-  isarray@2.0.5: {}
-
-  isexe@2.0.0: {}
-
-  iterator.prototype@1.1.5:
-    dependencies:
-      define-data-property: 1.1.4
-      es-object-atoms: 1.1.1
-      get-intrinsic: 1.3.0
-      get-proto: 1.0.1
-      has-symbols: 1.1.0
-      set-function-name: 2.0.2
-
-  jiti@2.6.1: {}
-
-  jose@6.1.3: {}
-
-  js-tokens@4.0.0: {}
-
-  js-yaml@4.1.1:
-    dependencies:
-      argparse: 2.0.1
-
-  jsesc@3.1.0: {}
-
-  json-buffer@3.0.1: {}
-
-  json-schema-traverse@0.4.1: {}
-
-  json-stable-stringify-without-jsonify@1.0.1: {}
-
-  json5@1.0.2:
-    dependencies:
-      minimist: 1.2.8
-
-  json5@2.2.3: {}
-
-  jsx-ast-utils@3.3.5:
-    dependencies:
-      array-includes: 3.1.9
-      array.prototype.flat: 1.3.3
-      object.assign: 4.1.7
-      object.values: 1.2.1
-
-  keyv@4.5.4:
-    dependencies:
-      json-buffer: 3.0.1
-
-  language-subtag-registry@0.3.23: {}
-
-  language-tags@1.0.9:
-    dependencies:
-      language-subtag-registry: 0.3.23
-
-  levn@0.4.1:
-    dependencies:
-      prelude-ls: 1.2.1
-      type-check: 0.4.0
-
-  lightningcss-android-arm64@1.30.2:
-    optional: true
-
-  lightningcss-darwin-arm64@1.30.2:
-    optional: true
-
-  lightningcss-darwin-x64@1.30.2:
-    optional: true
-
-  lightningcss-freebsd-x64@1.30.2:
-    optional: true
-
-  lightningcss-linux-arm-gnueabihf@1.30.2:
-    optional: true
-
-  lightningcss-linux-arm64-gnu@1.30.2:
-    optional: true
-
-  lightningcss-linux-arm64-musl@1.30.2:
-    optional: true
-
-  lightningcss-linux-x64-gnu@1.30.2:
-    optional: true
-
-  lightningcss-linux-x64-musl@1.30.2:
-    optional: true
-
-  lightningcss-win32-arm64-msvc@1.30.2:
-    optional: true
-
-  lightningcss-win32-x64-msvc@1.30.2:
-    optional: true
-
-  lightningcss@1.30.2:
-    dependencies:
-      detect-libc: 2.1.2
-    optionalDependencies:
-      lightningcss-android-arm64: 1.30.2
-      lightningcss-darwin-arm64: 1.30.2
-      lightningcss-darwin-x64: 1.30.2
-      lightningcss-freebsd-x64: 1.30.2
-      lightningcss-linux-arm-gnueabihf: 1.30.2
-      lightningcss-linux-arm64-gnu: 1.30.2
-      lightningcss-linux-arm64-musl: 1.30.2
-      lightningcss-linux-x64-gnu: 1.30.2
-      lightningcss-linux-x64-musl: 1.30.2
-      lightningcss-win32-arm64-msvc: 1.30.2
-      lightningcss-win32-x64-msvc: 1.30.2
-
-  locate-path@6.0.0:
-    dependencies:
-      p-locate: 5.0.0
-
-  lodash.merge@4.6.2: {}
-
-  loose-envify@1.4.0:
-    dependencies:
-      js-tokens: 4.0.0
-
-  lru-cache@5.1.1:
-    dependencies:
-      yallist: 3.1.1
-
-  magic-string@0.30.21:
-    dependencies:
-      '@jridgewell/sourcemap-codec': 1.5.5
-
-  math-intrinsics@1.1.0: {}
-
-  merge2@1.4.1: {}
-
-  micromatch@4.0.8:
-    dependencies:
-      braces: 3.0.3
-      picomatch: 2.3.1
-
-  mini-svg-data-uri@1.4.4: {}
-
-  minimatch@3.1.2:
-    dependencies:
-      brace-expansion: 1.1.12
-
-  minimatch@9.0.5:
-    dependencies:
-      brace-expansion: 2.0.2
-
-  minimist@1.2.8: {}
-
-  ms@2.1.3: {}
-
-  nanoid@3.3.11: {}
-
-  napi-postinstall@0.3.4: {}
-
-  natural-compare@1.4.0: {}
-
-  next-auth@5.0.0-beta.30(next@15.5.9(@babel/core@7.28.6)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3):
-    dependencies:
-      '@auth/core': 0.41.0
-      next: 15.5.9(@babel/core@7.28.6)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
-      react: 19.2.3
-
-  next@15.5.9(@babel/core@7.28.6)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
-    dependencies:
-      '@next/env': 15.5.9
-      '@swc/helpers': 0.5.15
-      caniuse-lite: 1.0.30001765
-      postcss: 8.4.31
-      react: 19.2.3
-      react-dom: 19.2.3(react@19.2.3)
-      styled-jsx: 5.1.6(@babel/core@7.28.6)(react@19.2.3)
-    optionalDependencies:
-      '@next/swc-darwin-arm64': 15.5.7
-      '@next/swc-darwin-x64': 15.5.7
-      '@next/swc-linux-arm64-gnu': 15.5.7
-      '@next/swc-linux-arm64-musl': 15.5.7
-      '@next/swc-linux-x64-gnu': 15.5.7
-      '@next/swc-linux-x64-musl': 15.5.7
-      '@next/swc-win32-arm64-msvc': 15.5.7
-      '@next/swc-win32-x64-msvc': 15.5.7
-      sharp: 0.34.5
-    transitivePeerDependencies:
-      - '@babel/core'
-      - babel-plugin-macros
-
-  node-addon-api@8.5.0: {}
-
-  node-gyp-build@4.8.4: {}
-
-  node-releases@2.0.27: {}
-
-  oauth4webapi@3.8.3: {}
-
-  object-assign@4.1.1: {}
-
-  object-inspect@1.13.4: {}
-
-  object-keys@1.1.1: {}
-
-  object.assign@4.1.7:
-    dependencies:
-      call-bind: 1.0.8
-      call-bound: 1.0.4
-      define-properties: 1.2.1
-      es-object-atoms: 1.1.1
-      has-symbols: 1.1.0
-      object-keys: 1.1.1
-
-  object.entries@1.1.9:
-    dependencies:
-      call-bind: 1.0.8
-      call-bound: 1.0.4
-      define-properties: 1.2.1
-      es-object-atoms: 1.1.1
-
-  object.fromentries@2.0.8:
-    dependencies:
-      call-bind: 1.0.8
-      define-properties: 1.2.1
-      es-abstract: 1.24.1
-      es-object-atoms: 1.1.1
-
-  object.groupby@1.0.3:
-    dependencies:
-      call-bind: 1.0.8
-      define-properties: 1.2.1
-      es-abstract: 1.24.1
-
-  object.values@1.2.1:
-    dependencies:
-      call-bind: 1.0.8
-      call-bound: 1.0.4
-      define-properties: 1.2.1
-      es-object-atoms: 1.1.1
-
-  optionator@0.9.4:
-    dependencies:
-      deep-is: 0.1.4
-      fast-levenshtein: 2.0.6
-      levn: 0.4.1
-      prelude-ls: 1.2.1
-      type-check: 0.4.0
-      word-wrap: 1.2.5
-
-  own-keys@1.0.1:
-    dependencies:
-      get-intrinsic: 1.3.0
-      object-keys: 1.1.1
-      safe-push-apply: 1.0.0
-
-  p-limit@3.1.0:
-    dependencies:
-      yocto-queue: 0.1.0
-
-  p-locate@5.0.0:
-    dependencies:
-      p-limit: 3.1.0
-
-  parent-module@1.0.1:
-    dependencies:
-      callsites: 3.1.0
-
-  path-exists@4.0.0: {}
-
-  path-key@3.1.1: {}
-
-  path-parse@1.0.7: {}
-
-  picocolors@1.1.1: {}
-
-  picomatch@2.3.1: {}
-
-  picomatch@4.0.3: {}
-
-  possible-typed-array-names@1.1.0: {}
-
-  postcss-value-parser@4.2.0: {}
-
-  postcss@8.4.31:
-    dependencies:
-      nanoid: 3.3.11
-      picocolors: 1.1.1
-      source-map-js: 1.2.1
-
-  postcss@8.5.6:
-    dependencies:
-      nanoid: 3.3.11
-      picocolors: 1.1.1
-      source-map-js: 1.2.1
-
-  postgres@3.4.8: {}
-
-  preact-render-to-string@6.5.11(preact@10.24.3):
-    dependencies:
-      preact: 10.24.3
-
-  preact@10.24.3: {}
-
-  prelude-ls@1.2.1: {}
-
-  prop-types@15.8.1:
-    dependencies:
-      loose-envify: 1.4.0
-      object-assign: 4.1.1
-      react-is: 16.13.1
-
-  punycode@2.3.1: {}
-
-  queue-microtask@1.2.3: {}
-
-  react-dom@19.2.3(react@19.2.3):
-    dependencies:
-      react: 19.2.3
-      scheduler: 0.27.0
-
-  react-is@16.13.1: {}
-
-  react@19.2.3: {}
-
-  reflect.getprototypeof@1.0.10:
-    dependencies:
-      call-bind: 1.0.8
-      define-properties: 1.2.1
-      es-abstract: 1.24.1
-      es-errors: 1.3.0
-      es-object-atoms: 1.1.1
-      get-intrinsic: 1.3.0
-      get-proto: 1.0.1
-      which-builtin-type: 1.2.1
-
-  regexp.prototype.flags@1.5.4:
-    dependencies:
-      call-bind: 1.0.8
-      define-properties: 1.2.1
-      es-errors: 1.3.0
-      get-proto: 1.0.1
-      gopd: 1.2.0
-      set-function-name: 2.0.2
-
-  resolve-from@4.0.0: {}
-
-  resolve-pkg-maps@1.0.0: {}
-
-  resolve@1.22.11:
-    dependencies:
-      is-core-module: 2.16.1
-      path-parse: 1.0.7
-      supports-preserve-symlinks-flag: 1.0.0
-
-  resolve@2.0.0-next.5:
-    dependencies:
-      is-core-module: 2.16.1
-      path-parse: 1.0.7
-      supports-preserve-symlinks-flag: 1.0.0
-
-  reusify@1.1.0: {}
-
-  run-parallel@1.2.0:
-    dependencies:
-      queue-microtask: 1.2.3
-
-  safe-array-concat@1.1.3:
-    dependencies:
-      call-bind: 1.0.8
-      call-bound: 1.0.4
-      get-intrinsic: 1.3.0
-      has-symbols: 1.1.0
-      isarray: 2.0.5
-
-  safe-push-apply@1.0.0:
-    dependencies:
-      es-errors: 1.3.0
-      isarray: 2.0.5
-
-  safe-regex-test@1.1.0:
-    dependencies:
-      call-bound: 1.0.4
-      es-errors: 1.3.0
-      is-regex: 1.2.1
-
-  scheduler@0.27.0: {}
-
-  semver@6.3.1: {}
-
-  semver@7.7.3: {}
-
-  set-function-length@1.2.2:
-    dependencies:
-      define-data-property: 1.1.4
-      es-errors: 1.3.0
-      function-bind: 1.1.2
-      get-intrinsic: 1.3.0
-      gopd: 1.2.0
-      has-property-descriptors: 1.0.2
-
-  set-function-name@2.0.2:
-    dependencies:
-      define-data-property: 1.1.4
-      es-errors: 1.3.0
-      functions-have-names: 1.2.3
-      has-property-descriptors: 1.0.2
-
-  set-proto@1.0.0:
-    dependencies:
-      dunder-proto: 1.0.1
-      es-errors: 1.3.0
-      es-object-atoms: 1.1.1
-
-  sharp@0.34.5:
-    dependencies:
-      '@img/colour': 1.0.0
-      detect-libc: 2.1.2
-      semver: 7.7.3
-    optionalDependencies:
-      '@img/sharp-darwin-arm64': 0.34.5
-      '@img/sharp-darwin-x64': 0.34.5
-      '@img/sharp-libvips-darwin-arm64': 1.2.4
-      '@img/sharp-libvips-darwin-x64': 1.2.4
-      '@img/sharp-libvips-linux-arm': 1.2.4
-      '@img/sharp-libvips-linux-arm64': 1.2.4
-      '@img/sharp-libvips-linux-ppc64': 1.2.4
-      '@img/sharp-libvips-linux-riscv64': 1.2.4
-      '@img/sharp-libvips-linux-s390x': 1.2.4
-      '@img/sharp-libvips-linux-x64': 1.2.4
-      '@img/sharp-libvips-linuxmusl-arm64': 1.2.4
-      '@img/sharp-libvips-linuxmusl-x64': 1.2.4
-      '@img/sharp-linux-arm': 0.34.5
-      '@img/sharp-linux-arm64': 0.34.5
-      '@img/sharp-linux-ppc64': 0.34.5
-      '@img/sharp-linux-riscv64': 0.34.5
-      '@img/sharp-linux-s390x': 0.34.5
-      '@img/sharp-linux-x64': 0.34.5
-      '@img/sharp-linuxmusl-arm64': 0.34.5
-      '@img/sharp-linuxmusl-x64': 0.34.5
-      '@img/sharp-wasm32': 0.34.5
-      '@img/sharp-win32-arm64': 0.34.5
-      '@img/sharp-win32-ia32': 0.34.5
-      '@img/sharp-win32-x64': 0.34.5
-    optional: true
-
-  shebang-command@2.0.0:
-    dependencies:
-      shebang-regex: 3.0.0
-
-  shebang-regex@3.0.0: {}
-
-  side-channel-list@1.0.0:
-    dependencies:
-      es-errors: 1.3.0
-      object-inspect: 1.13.4
-
-  side-channel-map@1.0.1:
-    dependencies:
-      call-bound: 1.0.4
-      es-errors: 1.3.0
-      get-intrinsic: 1.3.0
-      object-inspect: 1.13.4
-
-  side-channel-weakmap@1.0.2:
-    dependencies:
-      call-bound: 1.0.4
-      es-errors: 1.3.0
-      get-intrinsic: 1.3.0
-      object-inspect: 1.13.4
-      side-channel-map: 1.0.1
-
-  side-channel@1.1.0:
-    dependencies:
-      es-errors: 1.3.0
-      object-inspect: 1.13.4
-      side-channel-list: 1.0.0
-      side-channel-map: 1.0.1
-      side-channel-weakmap: 1.0.2
-
-  source-map-js@1.2.1: {}
-
-  stable-hash@0.0.5: {}
-
-  stop-iteration-iterator@1.1.0:
-    dependencies:
-      es-errors: 1.3.0
-      internal-slot: 1.1.0
-
-  string.prototype.includes@2.0.1:
-    dependencies:
-      call-bind: 1.0.8
-      define-properties: 1.2.1
-      es-abstract: 1.24.1
-
-  string.prototype.matchall@4.0.12:
-    dependencies:
-      call-bind: 1.0.8
-      call-bound: 1.0.4
-      define-properties: 1.2.1
-      es-abstract: 1.24.1
-      es-errors: 1.3.0
-      es-object-atoms: 1.1.1
-      get-intrinsic: 1.3.0
-      gopd: 1.2.0
-      has-symbols: 1.1.0
-      internal-slot: 1.1.0
-      regexp.prototype.flags: 1.5.4
-      set-function-name: 2.0.2
-      side-channel: 1.1.0
-
-  string.prototype.repeat@1.0.0:
-    dependencies:
-      define-properties: 1.2.1
-      es-abstract: 1.24.1
-
-  string.prototype.trim@1.2.10:
-    dependencies:
-      call-bind: 1.0.8
-      call-bound: 1.0.4
-      define-data-property: 1.1.4
-      define-properties: 1.2.1
-      es-abstract: 1.24.1
-      es-object-atoms: 1.1.1
-      has-property-descriptors: 1.0.2
-
-  string.prototype.trimend@1.0.9:
-    dependencies:
-      call-bind: 1.0.8
-      call-bound: 1.0.4
-      define-properties: 1.2.1
-      es-object-atoms: 1.1.1
-
-  string.prototype.trimstart@1.0.8:
-    dependencies:
-      call-bind: 1.0.8
-      define-properties: 1.2.1
-      es-object-atoms: 1.1.1
-
-  strip-bom@3.0.0: {}
-
-  strip-json-comments@3.1.1: {}
-
-  styled-jsx@5.1.6(@babel/core@7.28.6)(react@19.2.3):
-    dependencies:
-      client-only: 0.0.1
-      react: 19.2.3
-    optionalDependencies:
-      '@babel/core': 7.28.6
-
-  supports-color@7.2.0:
-    dependencies:
-      has-flag: 4.0.0
-
-  supports-preserve-symlinks-flag@1.0.0: {}
-
-  tailwindcss@4.1.18: {}
-
-  tapable@2.3.0: {}
-
-  tinyglobby@0.2.15:
-    dependencies:
-      fdir: 6.5.0(picomatch@4.0.3)
-      picomatch: 4.0.3
-
-  to-regex-range@5.0.1:
-    dependencies:
-      is-number: 7.0.0
-
-  ts-api-utils@2.4.0(typescript@5.9.3):
-    dependencies:
-      typescript: 5.9.3
-
-  tsconfig-paths@3.15.0:
-    dependencies:
-      '@types/json5': 0.0.29
-      json5: 1.0.2
-      minimist: 1.2.8
-      strip-bom: 3.0.0
-
-  tslib@2.8.1: {}
-
-  type-check@0.4.0:
-    dependencies:
-      prelude-ls: 1.2.1
-
-  typed-array-buffer@1.0.3:
-    dependencies:
-      call-bound: 1.0.4
-      es-errors: 1.3.0
-      is-typed-array: 1.1.15
-
-  typed-array-byte-length@1.0.3:
-    dependencies:
-      call-bind: 1.0.8
-      for-each: 0.3.5
-      gopd: 1.2.0
-      has-proto: 1.2.0
-      is-typed-array: 1.1.15
-
-  typed-array-byte-offset@1.0.4:
-    dependencies:
-      available-typed-arrays: 1.0.7
-      call-bind: 1.0.8
-      for-each: 0.3.5
-      gopd: 1.2.0
-      has-proto: 1.2.0
-      is-typed-array: 1.1.15
-      reflect.getprototypeof: 1.0.10
-
-  typed-array-length@1.0.7:
-    dependencies:
-      call-bind: 1.0.8
-      for-each: 0.3.5
-      gopd: 1.2.0
-      is-typed-array: 1.1.15
-      possible-typed-array-names: 1.1.0
-      reflect.getprototypeof: 1.0.10
-
-  typescript-eslint@8.53.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3):
-    dependencies:
-      '@typescript-eslint/eslint-plugin': 8.53.1(@typescript-eslint/parser@8.53.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
-      '@typescript-eslint/parser': 8.53.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
-      '@typescript-eslint/typescript-estree': 8.53.1(typescript@5.9.3)
-      '@typescript-eslint/utils': 8.53.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
-      eslint: 9.39.2(jiti@2.6.1)
-      typescript: 5.9.3
-    transitivePeerDependencies:
-      - supports-color
-
-  typescript@5.9.3: {}
-
-  unbox-primitive@1.1.0:
-    dependencies:
-      call-bound: 1.0.4
-      has-bigints: 1.1.0
-      has-symbols: 1.1.0
-      which-boxed-primitive: 1.1.1
-
-  undici-types@7.16.0: {}
-
-  unrs-resolver@1.11.1:
-    dependencies:
-      napi-postinstall: 0.3.4
-    optionalDependencies:
-      '@unrs/resolver-binding-android-arm-eabi': 1.11.1
-      '@unrs/resolver-binding-android-arm64': 1.11.1
-      '@unrs/resolver-binding-darwin-arm64': 1.11.1
-      '@unrs/resolver-binding-darwin-x64': 1.11.1
-      '@unrs/resolver-binding-freebsd-x64': 1.11.1
-      '@unrs/resolver-binding-linux-arm-gnueabihf': 1.11.1
-      '@unrs/resolver-binding-linux-arm-musleabihf': 1.11.1
-      '@unrs/resolver-binding-linux-arm64-gnu': 1.11.1
-      '@unrs/resolver-binding-linux-arm64-musl': 1.11.1
-      '@unrs/resolver-binding-linux-ppc64-gnu': 1.11.1
-      '@unrs/resolver-binding-linux-riscv64-gnu': 1.11.1
-      '@unrs/resolver-binding-linux-riscv64-musl': 1.11.1
-      '@unrs/resolver-binding-linux-s390x-gnu': 1.11.1
-      '@unrs/resolver-binding-linux-x64-gnu': 1.11.1
-      '@unrs/resolver-binding-linux-x64-musl': 1.11.1
-      '@unrs/resolver-binding-wasm32-wasi': 1.11.1
-      '@unrs/resolver-binding-win32-arm64-msvc': 1.11.1
-      '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1
-      '@unrs/resolver-binding-win32-x64-msvc': 1.11.1
-
-  update-browserslist-db@1.2.3(browserslist@4.28.1):
-    dependencies:
-      browserslist: 4.28.1
-      escalade: 3.2.0
-      picocolors: 1.1.1
-
-  uri-js@4.4.1:
-    dependencies:
-      punycode: 2.3.1
-
-  use-debounce@10.1.0(react@19.2.3):
-    dependencies:
-      react: 19.2.3
-
-  which-boxed-primitive@1.1.1:
-    dependencies:
-      is-bigint: 1.1.0
-      is-boolean-object: 1.2.2
-      is-number-object: 1.1.1
-      is-string: 1.1.1
-      is-symbol: 1.1.1
-
-  which-builtin-type@1.2.1:
-    dependencies:
-      call-bound: 1.0.4
-      function.prototype.name: 1.1.8
-      has-tostringtag: 1.0.2
-      is-async-function: 2.1.1
-      is-date-object: 1.1.0
-      is-finalizationregistry: 1.1.1
-      is-generator-function: 1.1.2
-      is-regex: 1.2.1
-      is-weakref: 1.1.1
-      isarray: 2.0.5
-      which-boxed-primitive: 1.1.1
-      which-collection: 1.0.2
-      which-typed-array: 1.1.20
-
-  which-collection@1.0.2:
-    dependencies:
-      is-map: 2.0.3
-      is-set: 2.0.3
-      is-weakmap: 2.0.2
-      is-weakset: 2.0.4
-
-  which-typed-array@1.1.20:
-    dependencies:
-      available-typed-arrays: 1.0.7
-      call-bind: 1.0.8
-      call-bound: 1.0.4
-      for-each: 0.3.5
-      get-proto: 1.0.1
-      gopd: 1.2.0
-      has-tostringtag: 1.0.2
-
-  which@2.0.2:
-    dependencies:
-      isexe: 2.0.0
-
-  word-wrap@1.2.5: {}
-
-  yallist@3.1.1: {}
-
-  yocto-queue@0.1.0: {}
-
-  zod-validation-error@4.0.2(zod@4.3.5):
-    dependencies:
-      zod: 4.3.5
-
-  zod@4.3.5: {}
Index: stcss.config.mjs
===================================================================
--- postcss.config.mjs	(revision 5ee2d79a544d8f29386e3bbc597e35433bb9b89a)
+++ 	(revision )
@@ -1,6 +1,0 @@
-export default {
-  plugins: {
-    '@tailwindcss/postcss': {},
-    // autoprefixer is no longer needed in v4, as it's included by default with LightningCSS
-  },
-};
Index: ilwind.config.ts
===================================================================
--- tailwind.config.ts	(revision 5ee2d79a544d8f29386e3bbc597e35433bb9b89a)
+++ 	(revision )
@@ -1,29 +1,0 @@
-import type { Config } from 'tailwindcss';
-
-const config: Config = {
-  content: [
-    './pages/**/*.{js,ts,jsx,tsx,mdx}',
-    './components/**/*.{js,ts,jsx,tsx,mdx}',
-    './app/**/*.{js,ts,jsx,tsx,mdx}',
-  ],
-  theme: {
-    extend: {
-      colors: {
-        fein: {
-          primary: '#249e86',
-          primaryDark: '#1d7f6a',
-          accent: '#1d7f6a',
-        },
-      },
-    },
-    keyframes: {
-      shimmer: {
-        '100%': {
-          transform: 'translateX(100%)',
-        },
-      },
-    },
-  },
-  plugins: [require('@tailwindcss/forms')],
-};
-export default config;
Index: config.json
===================================================================
--- tsconfig.json	(revision 5ee2d79a544d8f29386e3bbc597e35433bb9b89a)
+++ 	(revision )
@@ -1,44 +1,0 @@
-{
-  "compilerOptions": {
-    "target": "ES2017",
-    "lib": [
-      "dom",
-      "dom.iterable",
-      "esnext"
-    ],
-    "allowJs": true,
-    "skipLibCheck": true,
-    "strict": true,
-    "noEmit": true,
-    "esModuleInterop": true,
-    "module": "esnext",
-    "moduleResolution": "bundler",
-    "resolveJsonModule": true,
-    "isolatedModules": true,
-    "jsx": "preserve",
-    "incremental": true,
-    "plugins": [
-      {
-        "name": "next"
-      }
-    ],
-    "baseUrl": ".",
-    "paths": {
-      "@/*": [
-        "./*"
-      ]
-    }
-  },
-  "include": [
-    "next-env.d.ts",
-    "**/*.ts",
-    "**/*.tsx",
-    ".next/types/**/*.ts",
-    "app/lib/placeholder-data.ts",
-    "scripts/seed.js",
-    ".next/dev/types/**/*.ts"
-  ],
-  "exclude": [
-    "node_modules"
-  ]
-}
