commit 050f2316b22a3173c569acc3ceb7c596550ee1fe Author: data Date: Mon Jan 26 19:48:26 2026 +0100 MultiDocument Support diff --git a/.project b/.project new file mode 100755 index 0000000..63c38bd --- /dev/null +++ b/.project @@ -0,0 +1,11 @@ + + + subtotaltitle + + + + + + + + diff --git a/COPYING b/COPYING new file mode 100755 index 0000000..94a0453 --- /dev/null +++ b/COPYING @@ -0,0 +1,621 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + 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 diff --git a/ChangeLog.md b/ChangeLog.md new file mode 100755 index 0000000..a182cf7 --- /dev/null +++ b/ChangeLog.md @@ -0,0 +1,5 @@ +# CHANGELOG MODULE SUBTOTALTITLE FOR [DOLIBARR ERP CRM](https://www.dolibarr.org) + +## 1.0 + +Initial version diff --git a/MIGRATION_MULTITYPE.md b/MIGRATION_MULTITYPE.md new file mode 100644 index 0000000..d27b8e8 --- /dev/null +++ b/MIGRATION_MULTITYPE.md @@ -0,0 +1,166 @@ +# Migration zu Multi-Dokumenttyp-Unterstützung + +## Übersicht +Diese Migration erweitert das SubtotalTitle-Modul von der ausschließlichen Unterstützung für Rechnungen auf: +- ✅ **Rechnungen** (invoice/facture) +- ✅ **Angebote** (propal) +- ✅ **Kundenaufträge** (order/commande) + +## Durchgeführte Änderungen + +### 1. Datenbank-Schema (✅ Erledigt) +**Datei:** `sql/llx_facture_lines_manager.sql` + +Neue Spalten zur Tabelle `llx_facture_lines_manager`: +- `document_type` VARCHAR(20) - Art des Dokuments ('invoice', 'propal', 'order') +- `fk_propal` INT(11) - Referenz auf Angebot +- `fk_commande` INT(11) - Referenz auf Kundenauftrag +- `fk_propaldet` INT(11) - Referenz auf Angebots-Zeile +- `fk_commandedet` INT(11) - Referenz auf Auftrags-Zeile + +**Indizes** für bessere Performance hinzugefügt. + +### 2. Modul-Konfiguration (✅ Erledigt) +**Datei:** `core/modules/modSubtotalTitle.class.php` + +- Hooks erweitert: `invoicecard`, `propalcard`, `ordercard` +- Beschreibung aktualisiert + +### 3. Helper-Klasse (✅ Neu erstellt) +**Datei:** `class/DocumentTypeHelper.class.php` + +Zentrale Klasse zur Verwaltung verschiedener Dokumenttypen: +- `getTypeFromContext()` - Erkennt Typ aus Hook-Context +- `getTypeFromObject()` - Erkennt Typ aus Dolibarr-Objekt +- `getTableNames()` - Liefert DB-Tabellennamen für jeden Typ +- `getContext()` - Liefert Hook-Context für jeden Typ + +### 4. Hook-Implementierung (✅ Teilweise erledigt) +**Datei:** `class/actions_subtotaltitle.class.php` + +Angepasste Methoden: +- `formObjectOptions()` - Unterstützt alle 3 Dokumenttypen +- `printObjectLine()` - Generisch für alle Typen +- `formAddObjectLine()` - Generisch für alle Typen +- `syncManagerTable()` - Generisch für alle Typen +- `renderSectionDropdown()` - Generisch für alle Typen +- `getNextLineOrder()` - Generisch für alle Typen + +**WICHTIG:** Die Methode `renderAllPendingSections()` wurde teilweise angepasst, benötigt aber noch weitere Überprüfung. + +### 5. JavaScript (⚠️ Teilweise erledigt) +**Datei:** `js/subtotaltitle.js` + +Hinzugefügt: +- `getDocumentType()` - Erkennt Dokumenttyp aus URL + +**TODO:** JavaScript-Code muss noch vollständig generisch gemacht werden. + +### 6. AJAX-Dateien (❌ Noch zu erledigen) +Alle AJAX-Dateien im Verzeichnis `ajax/` müssen angepasst werden: +- `create_section.php` +- `move_section.php` +- `delete_section.php` +- `rename_section.php` +- `create_textline.php` +- `edit_textline.php` +- `delete_textline.php` +- `assign_last_product.php` +- `move_product.php` +- `remove_from_section.php` +- `reorder_all.php` +- `toggle_subtotal.php` +- `mass_delete.php` +- `sync_to_facturedet.php` +- `get_sections.php` +- `get_textlines.php` +- `get_line_orders.php` + +**Anpassung:** Jede Datei muss: +1. `document_type` Parameter empfangen/erkennen +2. `DocumentTypeHelper` verwenden +3. Korrekte FK-Spalten verwenden (fk_facture/fk_propal/fk_commande) + +## Installationsschritte + +### Schritt 1: Datenbank Migration +```bash +mysql -u root -p dolibarr < /srv/http/dolibarr/custom/subtotaltitle/sql/llx_facture_lines_manager.sql +``` + +### Schritt 2: Modul neu laden +1. In Dolibarr: Home → Setup → Modules +2. SubtotalTitle Modul deaktivieren +3. SubtotalTitle Modul aktivieren + +### Schritt 3: Cache leeren +```bash +rm -rf /srv/http/dolibarr/documents/admin/temp/* +``` + +## Testen + +### Test-Checkliste + +#### Rechnungen (Bestand - sollte weiter funktionieren) +- [ ] Section erstellen +- [ ] Produkte zur Section hinzufügen +- [ ] Textzeilen erstellen +- [ ] Zwischensummen anzeigen +- [ ] Drag & Drop +- [ ] Sync zu facturedet + +#### Angebote (NEU) +- [ ] Section erstellen +- [ ] Produkte zur Section hinzufügen +- [ ] Textzeilen erstellen +- [ ] Zwischensummen anzeigen +- [ ] Drag & Drop + +#### Kundenaufträge (NEU) +- [ ] Section erstellen +- [ ] Produkte zur Section hinzufügen +- [ ] Textzeilen erstellen +- [ ] Zwischensummen anzeigen +- [ ] Drag & Drop + +## Bekannte Probleme / TODOs + +1. **AJAX-Dateien noch nicht angepasst** - Alle AJAX-Calls verwenden noch `facture_id` statt generischem `document_id` + +2. **JavaScript teilweise angepasst** - Viele Funktionen verwenden noch `facture_id` statt `document_id` + `document_type` + +3. **Sync-Funktionalität** - Die Sync-zu-PDF-Funktionalität (`in_facturedet`) muss für Angebote und Aufträge getestet werden + +4. **PDF-Templates** - Eventuell müssen auch PDF-Templates angepasst werden + +5. **Substitutions** - Die Substitutions-Funktionen müssen eventuell erweitert werden + +## Nächste Schritte (Priorität) + +1. **AJAX-Dateien anpassen** (HOCH) + - Template-Beispiel erstellen + - Alle AJAX-Dateien nach Template anpassen + +2. **JavaScript vollständig generisch machen** (HOCH) + - `facture_id` durch `document_id` ersetzen + - `document_type` überall hinzufügen + +3. **Testen** (MITTEL) + - Mit Angeboten testen + - Mit Kundenaufträgen testen + +4. **renderAllPendingSections überprüfen** (MITTEL) + - SQL-Queries auf Korrektheit prüfen + - Alle `$doc_key` vs `$document_id` Verwendungen überprüfen + +## Support + +Bei Fragen oder Problemen: +- Dokumentation: `/srv/http/dolibarr/custom/subtotaltitle/README.md` +- Code-Review empfohlen für: `class/actions_subtotaltitle.class.php` + +--- +**Version:** 1.1.0 +**Datum:** 2026-01-23 +**Autor:** Eduard Wisch diff --git a/README.md b/README.md new file mode 100755 index 0000000..e83d0f2 --- /dev/null +++ b/README.md @@ -0,0 +1,88 @@ +# SubtotalTitle - Facturedet Sync Update + +## Was ist neu? +- **📄 Checkbox** bei jeder Section/Textzeile/Subtotal: Element zur Rechnung hinzufügen +- **→ Zur Rechnung / ← Aus Rechnung** Buttons: Alle Elemente auf einmal +- **ODT-Variablen** für formatierte Ausgabe im PDF/ODT + +## Installation + +### 1. Dateien kopieren + +``` +subtotaltitle_complete/ +├── class/ +│ └── actions_subtotaltitle.class.php → ERSETZEN +├── ajax/ +│ └── sync_to_facturedet.php → NEU +├── js/ +│ └── subtotaltitle_sync.js → NEU +├── css/ +│ └── subtotaltitle_sync.css → Inhalt zu deiner CSS hinzufügen +└── core/ + └── substitutions/ + └── functions_subtotaltitle.lib.php → NEU (Ordner ggf. erstellen!) +``` + +### 2. Modul-Descriptor anpassen + +In `core/modules/modSubtotalTitle.class.php` ändern: + +```php +$this->module_parts = array( + 'substitutions' => 1, // ← Diese Zeile hinzufügen/ändern! + 'hooks' => array( + 'data' => array('invoicecard'), + 'entity' => '0' + ) +); +``` + +### 3. Modul deaktivieren und wieder aktivieren + +Damit die Substitution-Funktion erkannt wird. + +## Verwendung + +### In der Rechnungsansicht + +| Element | Checkbox | Bedeutung | +|---------|----------|-----------| +| Section | 📄 | Zur Rechnung hinzufügen | +| Textzeile | 📄 | Zur Rechnung hinzufügen | +| Subtotal | 📄 | Zur Rechnung hinzufügen | + +**Grüner Rand** = Element ist in der Rechnung/PDF enthalten + +### Im ODT-Template + +``` +[!-- BEGIN row.lines --] + +[!-- IF {line_is_section} --] +{line_desc} +[!-- ENDIF {line_is_section} --] + +[!-- IF {line_is_textline} --] +{line_desc} +[!-- ENDIF {line_is_textline} --] + +[!-- IF {line_is_normal} --] +{line_pos} {line_qty} {line_desc} {line_up_locale} € {line_price_ht_locale} € +[!-- ENDIF {line_is_normal} --] + +[!-- IF {line_is_subtotal} --] +Zwischensumme: {line_price_ht_locale} € +[!-- ENDIF {line_is_subtotal} --] + +[!-- END row.lines --] +``` + +## special_code Werte + +| Typ | special_code | ODT-Variable | +|-----|-------------|--------------| +| Normales Produkt | 0 | `{line_is_normal}` | +| Section | 100 | `{line_is_section}` | +| Textzeile | 101 | `{line_is_textline}` | +| Zwischensumme | 102 | `{line_is_subtotal}` | diff --git a/admin/about.php b/admin/about.php new file mode 100755 index 0000000..81116ae --- /dev/null +++ b/admin/about.php @@ -0,0 +1,118 @@ + + * Copyright (C) 2026 Eduard Wisch + * Copyright (C) 2024 Frédéric France + * + * 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 . + */ + +/** + * \file subtotaltitle/admin/about.php + * \ingroup subtotaltitle + * \brief About page of module SubtotalTitle. + */ + +// Load Dolibarr environment +$res = 0; +// Try main.inc.php into web root known defined into CONTEXT_DOCUMENT_ROOT (not always defined) +if (!$res && !empty($_SERVER["CONTEXT_DOCUMENT_ROOT"])) { + $res = @include $_SERVER["CONTEXT_DOCUMENT_ROOT"]."/main.inc.php"; +} +// Try main.inc.php into web root detected using web root calculated from SCRIPT_FILENAME +$tmp = empty($_SERVER['SCRIPT_FILENAME']) ? '' : $_SERVER['SCRIPT_FILENAME']; +$tmp2 = realpath(__FILE__); +$i = strlen($tmp) - 1; +$j = strlen($tmp2) - 1; +while ($i > 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) { + $i--; + $j--; +} +if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) { + $res = @include substr($tmp, 0, ($i + 1))."/main.inc.php"; +} +if (!$res && $i > 0 && file_exists(dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php")) { + $res = @include dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php"; +} +// Try main.inc.php using relative path +if (!$res && file_exists("../../main.inc.php")) { + $res = @include "../../main.inc.php"; +} +if (!$res && file_exists("../../../main.inc.php")) { + $res = @include "../../../main.inc.php"; +} +if (!$res) { + die("Include of main fails"); +} + +// Libraries +require_once DOL_DOCUMENT_ROOT.'/core/lib/admin.lib.php'; +require_once DOL_DOCUMENT_ROOT.'/core/lib/functions2.lib.php'; +require_once '../lib/subtotaltitle.lib.php'; + +/** + * @var Conf $conf + * @var DoliDB $db + * @var HookManager $hookmanager + * @var Translate $langs + * @var User $user + */ + +// Translations +$langs->loadLangs(array("errors", "admin", "subtotaltitle@subtotaltitle")); + +// Access control +if (!$user->admin) { + accessforbidden(); +} + +// Parameters +$action = GETPOST('action', 'aZ09'); +$backtopage = GETPOST('backtopage', 'alpha'); + + +/* + * Actions + */ + +// None + + +/* + * View + */ + +$form = new Form($db); + +$help_url = ''; +$title = "SubtotalTitleSetup"; + +llxHeader('', $langs->trans($title), $help_url, '', 0, 0, '', '', '', 'mod-subtotaltitle page-admin_about'); + +// Subheader +$linkback = ''.$langs->trans("BackToModuleList").''; + +print load_fiche_titre($langs->trans($title), $linkback, 'title_setup'); + +// Configuration header +$head = subtotaltitleAdminPrepareHead(); +print dol_get_fiche_head($head, 'about', $langs->trans($title), 0, 'subtotaltitle@subtotaltitle'); + +dol_include_once('/subtotaltitle/core/modules/modSubtotalTitle.class.php'); +$tmpmodule = new modSubtotalTitle($db); +print $tmpmodule->getDescLong(); + +// Page end +print dol_get_fiche_end(); +llxFooter(); +$db->close(); diff --git a/admin/setup.php b/admin/setup.php new file mode 100755 index 0000000..aa27dc0 --- /dev/null +++ b/admin/setup.php @@ -0,0 +1,609 @@ + + * Copyright (C) 2024 Frédéric France + * Copyright (C) 2026 Eduard Wisch + * + * 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 . + */ + +/** + * \file subtotaltitle/admin/setup.php + * \ingroup subtotaltitle + * \brief SubtotalTitle setup page. + */ + +// Load Dolibarr environment +$res = 0; +// Try main.inc.php into web root known defined into CONTEXT_DOCUMENT_ROOT (not always defined) +if (!$res && !empty($_SERVER["CONTEXT_DOCUMENT_ROOT"])) { + $res = @include $_SERVER["CONTEXT_DOCUMENT_ROOT"]."/main.inc.php"; +} +// Try main.inc.php into web root detected using web root calculated from SCRIPT_FILENAME +$tmp = empty($_SERVER['SCRIPT_FILENAME']) ? '' : $_SERVER['SCRIPT_FILENAME']; +$tmp2 = realpath(__FILE__); +$i = strlen($tmp) - 1; +$j = strlen($tmp2) - 1; +while ($i > 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) { + $i--; + $j--; +} +if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) { + $res = @include substr($tmp, 0, ($i + 1))."/main.inc.php"; +} +if (!$res && $i > 0 && file_exists(dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php")) { + $res = @include dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php"; +} +// Try main.inc.php using relative path +if (!$res && file_exists("../../main.inc.php")) { + $res = @include "../../main.inc.php"; +} +if (!$res && file_exists("../../../main.inc.php")) { + $res = @include "../../../main.inc.php"; +} +if (!$res) { + die("Include of main fails"); +} + +// Libraries +require_once DOL_DOCUMENT_ROOT."/core/lib/admin.lib.php"; +require_once '../lib/subtotaltitle.lib.php'; +//require_once "../class/myclass.class.php"; + +/** + * @var Conf $conf + * @var DoliDB $db + * @var HookManager $hookmanager + * @var Translate $langs + * @var User $user + */ + +// Translations +$langs->loadLangs(array("admin", "subtotaltitle@subtotaltitle")); + +// Initialize a technical object to manage hooks of page. Note that conf->hooks_modules contains an array of hook context +/** @var HookManager $hookmanager */ +$hookmanager->initHooks(array('subtotaltitlesetup', 'globalsetup')); + +// Parameters +$action = GETPOST('action', 'aZ09'); +$backtopage = GETPOST('backtopage', 'alpha'); +$modulepart = GETPOST('modulepart', 'aZ09'); // Used by actions_setmoduleoptions.inc.php + +$value = GETPOST('value', 'alpha'); +$label = GETPOST('label', 'alpha'); +$scandir = GETPOST('scan_dir', 'alpha'); +$type = 'myobject'; + +$error = 0; +$setupnotempty = 0; + +// Access control +if (!$user->admin) { + accessforbidden(); +} + + +// Set this to 1 to use the factory to manage constants. Warning, the generated module will be compatible with version v15+ only +$useFormSetup = 1; + +if (!class_exists('FormSetup')) { + require_once DOL_DOCUMENT_ROOT.'/core/class/html.formsetup.class.php'; +} +$formSetup = new FormSetup($db); + +// Access control +if (!$user->admin) { + accessforbidden(); +} + + +// ========== SUBTOTALTITLE EINSTELLUNGEN ========== + +// ===== GRUNDEINSTELLUNGEN ===== +$formSetup->newItem('GeneralSettings')->setAsTitle(); + +// Debug-Modus +$item = $formSetup->newItem('SUBTOTALTITLE_DEBUG_MODE'); +$item->setAsYesNo(); +$item->defaultFieldValue = '0'; +$item->helpText = $langs->transnoentities('SUBTOTALTITLE_DEBUG_MODE_HELP'); + +// Standardmäßig alle Elemente in Rechnung anzeigen +$item = $formSetup->newItem('SUBTOTALTITLE_DEFAULT_IN_INVOICE'); +$item->setAsYesNo(); +$item->defaultFieldValue = '1'; +$item->helpText = $langs->transnoentities('SUBTOTALTITLE_DEFAULT_IN_INVOICE_HELP'); + +// Automatisch Zwischensummen anzeigen +$item = $formSetup->newItem('SUBTOTALTITLE_AUTO_SUBTOTALS'); +$item->setAsYesNo(); +$item->defaultFieldValue = '1'; +$item->helpText = $langs->transnoentities('SUBTOTALTITLE_AUTO_SUBTOTALS_HELP'); + +// ===== FUNKTIONEN ===== +$formSetup->newItem('FeatureSettings')->setAsTitle(); + +// Textzeilen aktivieren +$item = $formSetup->newItem('SUBTOTALTITLE_ENABLE_TEXTLINES'); +$item->setAsYesNo(); +$item->defaultFieldValue = '1'; +$item->helpText = $langs->transnoentities('SUBTOTALTITLE_ENABLE_TEXTLINES_HELP'); + +// Sections kollabierbar machen +$item = $formSetup->newItem('SUBTOTALTITLE_ENABLE_COLLAPSE'); +$item->setAsYesNo(); +$item->defaultFieldValue = '1'; +$item->helpText = $langs->transnoentities('SUBTOTALTITLE_ENABLE_COLLAPSE_HELP'); + +// Drag & Drop aktivieren +$item = $formSetup->newItem('SUBTOTALTITLE_ENABLE_DRAGDROP'); +$item->setAsYesNo(); +$item->defaultFieldValue = '1'; +$item->helpText = $langs->transnoentities('SUBTOTALTITLE_ENABLE_DRAGDROP_HELP'); + +// ===== ANZEIGEOPTIONEN ===== +$formSetup->newItem('DisplaySettings')->setAsTitle(); + +// Section-Header Hintergrundfarbe +$item = $formSetup->newItem('SUBTOTALTITLE_SECTION_BG_COLOR'); +$item->setAsColor(); +$item->defaultFieldValue = '#f0f0f0'; +$item->helpText = $langs->transnoentities('SUBTOTALTITLE_SECTION_BG_COLOR_HELP'); + +// Subtotal Hintergrundfarbe +$item = $formSetup->newItem('SUBTOTALTITLE_SUBTOTAL_BG_COLOR'); +$item->setAsColor(); +$item->defaultFieldValue = '#e8f4e8'; +$item->helpText = $langs->transnoentities('SUBTOTALTITLE_SUBTOTAL_BG_COLOR_HELP'); + +// End of definition of parameters + + +$setupnotempty += count($formSetup->items); + + +$dirmodels = array_merge(array('/'), (array) $conf->modules_parts['models']); + +$moduledir = 'subtotaltitle'; +$myTmpObjects = array(); +// TODO Scan list of objects to fill this array +$myTmpObjects['myobject'] = array('label' => 'MyObject', 'includerefgeneration' => 0, 'includedocgeneration' => 0, 'class' => 'MyObject'); + +$tmpobjectkey = GETPOST('object', 'aZ09'); +if ($tmpobjectkey && !array_key_exists($tmpobjectkey, $myTmpObjects)) { + accessforbidden('Bad value for object. Hack attempt ?'); +} + + +/* + * Actions + */ + +// For retrocompatibility Dolibarr < 15.0 +if (versioncompare(explode('.', DOL_VERSION), array(15)) < 0 && $action == 'update' && !empty($user->admin)) { + $formSetup->saveConfFromPost(); +} + +include DOL_DOCUMENT_ROOT.'/core/actions_setmoduleoptions.inc.php'; + +if ($action == 'updateMask') { + $maskconst = GETPOST('maskconst', 'aZ09'); + $maskvalue = GETPOST('maskvalue', 'alpha'); + + if ($maskconst && preg_match('/_MASK$/', $maskconst)) { + $res = dolibarr_set_const($db, $maskconst, $maskvalue, 'chaine', 0, '', $conf->entity); + if (!($res > 0)) { + $error++; + } + } + + if (!$error) { + setEventMessages($langs->trans("SetupSaved"), null, 'mesgs'); + } else { + setEventMessages($langs->trans("Error"), null, 'errors'); + } +} elseif ($action == 'specimen' && $tmpobjectkey) { + $modele = GETPOST('module', 'alpha'); + + $className = $myTmpObjects[$tmpobjectkey]['class']; + $tmpobject = new $className($db); + '@phan-var-force MyObject $tmpobject'; + $tmpobject->initAsSpecimen(); + + // Search template files + $file = ''; + $className = ''; + $dirmodels = array_merge(array('/'), (array) $conf->modules_parts['models']); + foreach ($dirmodels as $reldir) { + $file = dol_buildpath($reldir."core/modules/subtotaltitle/doc/pdf_".$modele."_".strtolower($tmpobjectkey).".modules.php", 0); + if (file_exists($file)) { + $className = "pdf_".$modele."_".strtolower($tmpobjectkey); + break; + } + } + + if ($className !== '') { + require_once $file; + + $module = new $className($db); + '@phan-var-force ModelePDFMyObject $module'; + + '@phan-var-force ModelePDFMyObject $module'; + + if ($module->write_file($tmpobject, $langs) > 0) { + header("Location: ".DOL_URL_ROOT."/document.php?modulepart=subtotaltitle-".strtolower($tmpobjectkey)."&file=SPECIMEN.pdf"); + return; + } else { + setEventMessages($module->error, null, 'errors'); + dol_syslog($module->error, LOG_ERR); + } + } else { + setEventMessages($langs->trans("ErrorModuleNotFound"), null, 'errors'); + dol_syslog($langs->trans("ErrorModuleNotFound"), LOG_ERR); + } +} elseif ($action == 'setmod') { + // TODO Check if numbering module chosen can be activated by calling method canBeActivated + if (!empty($tmpobjectkey)) { + $constforval = 'SUBTOTALTITLE_'.strtoupper($tmpobjectkey)."_ADDON"; + dolibarr_set_const($db, $constforval, $value, 'chaine', 0, '', $conf->entity); + } +} elseif ($action == 'set') { + // Activate a model + $ret = addDocumentModel($value, $type, $label, $scandir); +} elseif ($action == 'del') { + $ret = delDocumentModel($value, $type); + if ($ret > 0) { + if (!empty($tmpobjectkey)) { + $constforval = 'SUBTOTALTITLE_'.strtoupper($tmpobjectkey).'_ADDON_PDF'; + if (getDolGlobalString($constforval) == "$value") { + dolibarr_del_const($db, $constforval, $conf->entity); + } + } + } +} elseif ($action == 'setdoc') { + // Set or unset default model + if (!empty($tmpobjectkey)) { + $constforval = 'SUBTOTALTITLE_'.strtoupper($tmpobjectkey).'_ADDON_PDF'; + if (dolibarr_set_const($db, $constforval, $value, 'chaine', 0, '', $conf->entity)) { + // The constant that was read before the new set + // We therefore requires a variable to have a coherent view + $conf->global->{$constforval} = $value; + } + + // We disable/enable the document template (into llx_document_model table) + $ret = delDocumentModel($value, $type); + if ($ret > 0) { + $ret = addDocumentModel($value, $type, $label, $scandir); + } + } +} elseif ($action == 'unsetdoc') { + if (!empty($tmpobjectkey)) { + $constforval = 'SUBTOTALTITLE_'.strtoupper($tmpobjectkey).'_ADDON_PDF'; + dolibarr_del_const($db, $constforval, $conf->entity); + } +} + +$action = 'edit'; + + +/* + * View + */ + +$form = new Form($db); + +$help_url = ''; +$title = "SubtotalTitleSetup"; + +llxHeader('', $langs->trans($title), $help_url, '', 0, 0, '', '', '', 'mod-subtotaltitle page-admin'); + +// Subheader +$linkback = ''.$langs->trans("BackToModuleList").''; + +print load_fiche_titre($langs->trans($title), $linkback, 'title_setup'); + +// Configuration header +$head = subtotaltitleAdminPrepareHead(); +print dol_get_fiche_head($head, 'settings', $langs->trans($title), -1, "subtotaltitle@subtotaltitle"); + +// Setup page goes here +echo ''.$langs->trans("SubtotalTitleSetupPage").'

'; + + +/*if ($action == 'edit') { + print $formSetup->generateOutput(true); + print '
'; + } elseif (!empty($formSetup->items)) { + print $formSetup->generateOutput(); + print '
'; + print ''.$langs->trans("Modify").''; + print '
'; + } + */ +if (!empty($formSetup->items)) { + print $formSetup->generateOutput(true); + print '
'; +} + + +foreach ($myTmpObjects as $myTmpObjectKey => $myTmpObjectArray) { + if (!empty($myTmpObjectArray['includerefgeneration'])) { + // Numbering models + + $setupnotempty++; + + print load_fiche_titre($langs->trans("NumberingModules", $myTmpObjectArray['label']), '', ''); + + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''."\n"; + + clearstatcache(); + + foreach ($dirmodels as $reldir) { + $dir = dol_buildpath($reldir."core/modules/".$moduledir); + + if (is_dir($dir)) { + $handle = opendir($dir); + if (is_resource($handle)) { + while (($file = readdir($handle)) !== false) { + if (strpos($file, 'mod_'.strtolower($myTmpObjectKey).'_') === 0 && substr($file, dol_strlen($file) - 3, 3) == 'php') { + $file = substr($file, 0, dol_strlen($file) - 4); + + require_once $dir.'/'.$file.'.php'; + + $module = new $file($db); + '@phan-var-force ModeleNumRefMyObject $module'; + + // Show modules according to features level + if ($module->version == 'development' && getDolGlobalInt('MAIN_FEATURES_LEVEL') < 2) { + continue; + } + if ($module->version == 'experimental' && getDolGlobalInt('MAIN_FEATURES_LEVEL') < 1) { + continue; + } + + if ($module->isEnabled()) { + dol_include_once('/'.$moduledir.'/class/'.strtolower($myTmpObjectKey).'.class.php'); + + print ''; + + // Show example of numbering model + print ''."\n"; + + print ''; + + $className = $myTmpObjectArray['class']; + $mytmpinstance = new $className($db); + '@phan-var-force MyObject $mytmpinstance'; + $mytmpinstance->initAsSpecimen(); + + // Info + $htmltooltip = ''; + $htmltooltip .= ''.$langs->trans("Version").': '.$module->getVersion().'
'; + + $nextval = $module->getNextValue($mytmpinstance); + if ("$nextval" != $langs->trans("NotAvailable")) { // Keep " on nextval + $htmltooltip .= ''.$langs->trans("NextValue").': '; + if ($nextval) { + if (preg_match('/^Error/', $nextval) || $nextval == 'NotConfigured') { + $nextval = $langs->trans($nextval); + } + $htmltooltip .= $nextval.'
'; + } else { + $htmltooltip .= $langs->trans($module->error).'
'; + } + } + + print ''; + + print "\n"; + } + } + } + closedir($handle); + } + } + } + print "
'.$langs->trans("Name").''.$langs->trans("Description").''.$langs->trans("Example").''.$langs->trans("Status").''.$langs->trans("ShortInfo").'
'.$module->getName($langs)."\n"; + print $module->info($langs); + print ''; + $tmp = $module->getExample(); + if (preg_match('/^Error/', $tmp)) { + $langs->load("errors"); + print '
'.$langs->trans($tmp).'
'; + } elseif ($tmp == 'NotConfigured') { + print $langs->trans($tmp); + } else { + print $tmp; + } + print '
'; + $constforvar = 'SUBTOTALTITLE_'.strtoupper($myTmpObjectKey).'_ADDON'; + $defaultifnotset = 'thevaluetousebydefault'; + $activenumberingmodel = getDolGlobalString($constforvar, $defaultifnotset); + if ($activenumberingmodel == $file) { + print img_picto($langs->trans("Activated"), 'switch_on'); + } else { + print ''; + print img_picto($langs->trans("Disabled"), 'switch_off'); + print ''; + } + print ''; + print $form->textwithpicto('', $htmltooltip, 1, 'info'); + print '

\n"; + } + + if (!empty($myTmpObjectArray['includedocgeneration'])) { + /* + * Document templates generators + */ + $setupnotempty++; + $type = strtolower($myTmpObjectKey); + + print load_fiche_titre($langs->trans("DocumentModules", $myTmpObjectKey), '', ''); + + // Load array def with activated templates + $def = array(); + $sql = "SELECT nom"; + $sql .= " FROM ".$db->prefix()."document_model"; + $sql .= " WHERE type = '".$db->escape($type)."'"; + $sql .= " AND entity = ".$conf->entity; + $resql = $db->query($sql); + if ($resql) { + $i = 0; + $num_rows = $db->num_rows($resql); + while ($i < $num_rows) { + $array = $db->fetch_array($resql); + array_push($def, $array[0]); + $i++; + } + } else { + dol_print_error($db); + } + + print ''."\n"; + print ''."\n"; + print ''; + print ''; + print '\n"; + print '\n"; + print ''; + print ''; + print "\n"; + + clearstatcache(); + + foreach ($dirmodels as $reldir) { + foreach (array('', '/doc') as $valdir) { + $realpath = $reldir."core/modules/".$moduledir.$valdir; + $dir = dol_buildpath($realpath); + + if (is_dir($dir)) { + $handle = opendir($dir); + if (is_resource($handle)) { + $filelist = array(); + while (($file = readdir($handle)) !== false) { + $filelist[] = $file; + } + closedir($handle); + arsort($filelist); + + foreach ($filelist as $file) { + if (preg_match('/\.modules\.php$/i', $file) && preg_match('/^(pdf_|doc_)/', $file)) { + if (file_exists($dir.'/'.$file)) { + $name = substr($file, 4, dol_strlen($file) - 16); + $className = substr($file, 0, dol_strlen($file) - 12); + + require_once $dir.'/'.$file; + $module = new $className($db); + '@phan-var-force ModelePDFMyObject $module'; + + $modulequalified = 1; + if ($module->version == 'development' && getDolGlobalInt('MAIN_FEATURES_LEVEL') < 2) { + $modulequalified = 0; + } + if ($module->version == 'experimental' && getDolGlobalInt('MAIN_FEATURES_LEVEL') < 1) { + $modulequalified = 0; + } + + if ($modulequalified) { + print ''; + + // Active + if (in_array($name, $def)) { + print ''; + } else { + print '"; + } + + // Default + print ''; + + // Info + $htmltooltip = ''.$langs->trans("Name").': '.$module->name; + $htmltooltip .= '
'.$langs->trans("Type").': '.($module->type ? $module->type : $langs->trans("Unknown")); + if ($module->type == 'pdf') { + $htmltooltip .= '
'.$langs->trans("Width").'/'.$langs->trans("Height").': '.$module->page_largeur.'/'.$module->page_hauteur; + } + $htmltooltip .= '
'.$langs->trans("Path").': '.preg_replace('/^\//', '', $realpath).'/'.$file; + + $htmltooltip .= '

'.$langs->trans("FeaturesSupported").':'; + $htmltooltip .= '
'.$langs->trans("Logo").': '.yn($module->option_logo, 1, 1); + $htmltooltip .= '
'.$langs->trans("MultiLanguage").': '.yn($module->option_multilang, 1, 1); + + print ''; + + // Preview + print ''; + + print "\n"; + } + } + } + } + } + } + } + } + + print '
'.$langs->trans("Name").''.$langs->trans("Description").''.$langs->trans("Status")."'.$langs->trans("Default")."'.$langs->trans("ShortInfo").''.$langs->trans("Preview").'
'; + print(empty($module->name) ? $name : $module->name); + print "\n"; + if (method_exists($module, 'info')) { + print $module->info($langs); // @phan-suppress-current-line PhanUndeclaredMethod + } else { + print $module->description; + } + print ''."\n"; + print ''; + print img_picto($langs->trans("Enabled"), 'switch_on'); + print ''; + print ''."\n"; + print 'scandir).'&label='.urlencode($module->name).'">'.img_picto($langs->trans("Disabled"), 'switch_off').''; + print "'; + $constforvar = 'SUBTOTALTITLE_'.strtoupper($myTmpObjectKey).'_ADDON_PDF'; + if (getDolGlobalString($constforvar) == $name) { + //print img_picto($langs->trans("Default"), 'on'); + // Even if choice is the default value, we allow to disable it. Replace this with previous line if you need to disable unset + print 'scandir).'&label='.urlencode($module->name).'&type='.urlencode($type).'" alt="'.$langs->trans("Disable").'">'.img_picto($langs->trans("Enabled"), 'on').''; + } else { + print 'scandir).'&label='.urlencode($module->name).'" alt="'.$langs->trans("Default").'">'.img_picto($langs->trans("Disabled"), 'off').''; + } + print ''; + print $form->textwithpicto('', $htmltooltip, 1, 'info'); + print ''; + if ($module->type == 'pdf') { + $newname = preg_replace('/_'.preg_quote(strtolower($myTmpObjectKey), '/').'/', '', $name); + print ''.img_object($langs->trans("Preview"), 'pdf').''; + } else { + print img_object($langs->transnoentitiesnoconv("PreviewNotAvailable"), 'generic'); + } + print '
'; + } +} + +if (empty($setupnotempty)) { + print '
'.$langs->trans("NothingToSetup"); +} + +// Page end +print dol_get_fiche_end(); + +llxFooter(); +$db->close(); diff --git a/ajax/assign_last_product.php b/ajax/assign_last_product.php new file mode 100755 index 0000000..4031bff --- /dev/null +++ b/ajax/assign_last_product.php @@ -0,0 +1,100 @@ + false, 'error' => 'Missing parameters']); + exit; +} + +// Hole die richtigen Tabellennamen für diesen Dokumenttyp +$tables = DocumentTypeHelper::getTableNames($docType); +if (!$tables) { + echo json_encode(['success' => false, 'error' => 'Invalid document type']); + exit; +} + +$db->begin(); + +// Hole das neueste Produkt dieses Dokuments (höchster rang) +$sql = "SELECT rowid, rang FROM ".MAIN_DB_PREFIX.$tables['lines_table']; +$sql .= " WHERE ".$tables['fk_parent']." = ".(int)$facture_id; +$sql .= " ORDER BY rang DESC LIMIT 1"; +$resql = $db->query($sql); + +if (!$resql || $db->num_rows($resql) == 0) { + $db->rollback(); + echo json_encode(['success' => false, 'error' => 'Kein Produkt gefunden']); + exit; +} + +$product = $db->fetch_object($resql); +$product_id = $product->rowid; + +subtotaltitle_debug_log(' → Neustes Produkt: #' . $product_id . ' (rang=' . $product->rang . ')'); + +// Prüfe ob schon in Manager-Tabelle (anhand der Detail-FK-Spalte) +$sql = "SELECT rowid FROM ".MAIN_DB_PREFIX."facture_lines_manager"; +$sql .= " WHERE ".$tables['fk_line']." = ".(int)$product_id; +$resql = $db->query($sql); + +if ($db->num_rows($resql) == 0) { + // Produkt fehlt - hinzufügen + $next_order = 1; + $sql_max = "SELECT MAX(line_order) as max_order FROM ".MAIN_DB_PREFIX."facture_lines_manager"; + $sql_max .= " WHERE ".$tables['fk_parent']." = ".(int)$facture_id; + $sql_max .= " AND document_type = '".$db->escape($docType)."'"; + $resql_max = $db->query($sql_max); + if ($obj = $db->fetch_object($resql_max)) { + $next_order = ($obj->max_order ? $obj->max_order + 1 : 1); + } + + // Setze alle FK-Felder explizit (NULL für nicht genutzte) + $fk_facture = ($docType === 'invoice') ? (int)$facture_id : 'NULL'; + $fk_propal = ($docType === 'propal') ? (int)$facture_id : 'NULL'; + $fk_commande = ($docType === 'order') ? (int)$facture_id : 'NULL'; + + $sql_ins = "INSERT INTO ".MAIN_DB_PREFIX."facture_lines_manager"; + $sql_ins .= " (fk_facture, fk_propal, fk_commande, document_type, line_type, ".$tables['fk_line'].", parent_section, line_order, date_creation)"; + $sql_ins .= " VALUES (".$fk_facture.", ".$fk_propal.", ".$fk_commande.", '".$db->escape($docType)."', 'product', ".(int)$product_id.", ".(int)$section_id.", ".$next_order.", NOW())"; + $db->query($sql_ins); + + subtotaltitle_debug_log(' → Produkt zu Manager-Tabelle hinzugefügt (line_order=' . $next_order . ')'); +} else { + // Produkt existiert - UPDATE parent_section + subtotaltitle_debug_log('🔵🔵🔵 assign_last_product: Produkt #'.$product_id.' → parent_section='.$section_id); + + $sql_upd = "UPDATE ".MAIN_DB_PREFIX."facture_lines_manager"; + $sql_upd .= " SET parent_section = ".(int)$section_id; + $sql_upd .= " WHERE ".$tables['fk_line']." = ".(int)$product_id; + $db->query($sql_upd); + + subtotaltitle_debug_log(' → parent_section updated'); +} + +// Neu sortieren +require_once DOL_DOCUMENT_ROOT.'/custom/subtotaltitle/class/actions_subtotaltitle.class.php'; +$hook = new ActionsSubtotalTitle($db); + +$reflection = new ReflectionClass($hook); +$method = $reflection->getMethod('reorderLines'); +$method->setAccessible(true); +$method->invoke($hook, $facture_id); + +$method = $reflection->getMethod('syncRangFromManager'); +$method->setAccessible(true); +$method->invoke($hook, $facture_id); + +$db->commit(); + +subtotaltitle_debug_log('✅ Assignment erfolgreich'); + +echo json_encode(['success' => true, 'product_id' => $product_id]); diff --git a/ajax/create_section.php b/ajax/create_section.php new file mode 100755 index 0000000..fd6ba05 --- /dev/null +++ b/ajax/create_section.php @@ -0,0 +1,53 @@ + false, 'error' => 'Missing parameters']); + exit; +} + +// Hole die richtigen Tabellennamen für diesen Dokumenttyp +$tables = DocumentTypeHelper::getTableNames($docType); +if (!$tables) { + echo json_encode(['success' => false, 'error' => 'Invalid document type']); + exit; +} + +// Hole nächste line_order +$sql = "SELECT MAX(line_order) as max_order"; +$sql .= " FROM ".MAIN_DB_PREFIX."facture_lines_manager"; +$sql .= " WHERE ".$tables['fk_parent']." = ".(int)$facture_id; +$sql .= " AND document_type = '".$db->escape($docType)."'"; +$resql = $db->query($sql); +$obj = $db->fetch_object($resql); +$next_order = ($obj && $obj->max_order ? $obj->max_order + 1 : 1); + +// Erstelle Section - setze alle FK-Felder explizit (NULL für nicht genutzte) +$fk_facture = ($docType === 'invoice') ? (int)$facture_id : 'NULL'; +$fk_propal = ($docType === 'propal') ? (int)$facture_id : 'NULL'; +$fk_commande = ($docType === 'order') ? (int)$facture_id : 'NULL'; + +$sql = "INSERT INTO ".MAIN_DB_PREFIX."facture_lines_manager"; +$sql .= " (fk_facture, fk_propal, fk_commande, document_type, line_type, title, line_order, date_creation)"; +$sql .= " VALUES (".$fk_facture.", ".$fk_propal.", ".$fk_commande.", '".$db->escape($docType)."', 'section', '".$db->escape($title)."', ".$next_order.", NOW())"; + +if ($db->query($sql)) { + $section_id = $db->last_insert_id(MAIN_DB_PREFIX."facture_lines_manager"); + + // Erstelle automatisch auch eine Zwischensumme für diese Section + $subtotal_order = $next_order + 1000; // Hohe Nummer, wird später normalisiert + $sql_subtotal = "INSERT INTO ".MAIN_DB_PREFIX."facture_lines_manager"; + $sql_subtotal .= " (fk_facture, fk_propal, fk_commande, document_type, line_type, title, parent_section, line_order, date_creation)"; + $sql_subtotal .= " VALUES (".$fk_facture.", ".$fk_propal.", ".$fk_commande.", '".$db->escape($docType)."', 'subtotal', 'Zwischensumme', ".(int)$section_id.", ".$subtotal_order.", NOW())"; + $db->query($sql_subtotal); + + echo json_encode(['success' => true, 'section_id' => $section_id]); +} else { + echo json_encode(['success' => false, 'error' => $db->lasterror()]); +} diff --git a/ajax/create_textline.php b/ajax/create_textline.php new file mode 100755 index 0000000..7bb0c44 --- /dev/null +++ b/ajax/create_textline.php @@ -0,0 +1,51 @@ + false, 'error' => 'Missing parameters')); + exit; +} + +// Hole die richtigen Tabellennamen für diesen Dokumenttyp +$tables = DocumentTypeHelper::getTableNames($docType); +if (!$tables) { + echo json_encode(array('success' => false, 'error' => 'Invalid document type')); + exit; +} + +// Hole nächste line_order +$sql = "SELECT MAX(line_order) as max_order FROM ".MAIN_DB_PREFIX."facture_lines_manager"; +$sql .= " WHERE ".$tables['fk_parent']." = ".(int)$facture_id; +$sql .= " AND document_type = '".$db->escape($docType)."'"; +$resql = $db->query($sql); +$obj = $db->fetch_object($resql); +$next_order = ($obj->max_order ? $obj->max_order + 1 : 1); + +// Füge Textzeile ein - setze alle FK-Felder explizit (NULL für nicht genutzte) +$fk_facture = ($docType === 'invoice') ? (int)$facture_id : 'NULL'; +$fk_propal = ($docType === 'propal') ? (int)$facture_id : 'NULL'; +$fk_commande = ($docType === 'order') ? (int)$facture_id : 'NULL'; + +$sql = "INSERT INTO ".MAIN_DB_PREFIX."facture_lines_manager"; +$sql .= " (fk_facture, fk_propal, fk_commande, document_type, line_type, title, line_order, date_creation)"; +$sql .= " VALUES (".$fk_facture.", ".$fk_propal.", ".$fk_commande.", '".$db->escape($docType)."', 'text', '".$db->escape($text)."', ".$next_order.", NOW())"; + +if ($db->query($sql)) { + $new_id = $db->last_insert_id(MAIN_DB_PREFIX."facture_lines_manager"); + echo json_encode(array('success' => true, 'id' => $new_id)); +} else { + echo json_encode(array('success' => false, 'error' => $db->lasterror())); +} diff --git a/ajax/delete_section.php b/ajax/delete_section.php new file mode 100755 index 0000000..b21a2a0 --- /dev/null +++ b/ajax/delete_section.php @@ -0,0 +1,189 @@ + false, 'error' => 'Missing parameters']); + exit; +} + +// 1. Hole Section-Info +$sql = "SELECT fk_facture FROM ".MAIN_DB_PREFIX."facture_lines_manager"; +$sql .= " WHERE rowid = ".(int)$section_id; +$sql .= " AND line_type = 'section'"; +$resql = $db->query($sql); + +if (!$resql || $db->num_rows($resql) == 0) { + echo json_encode(['success' => false, 'error' => 'Section not found']); + exit; +} + +$section = $db->fetch_object($resql); +$facture_id = $section->fk_facture; + +// 2. Prüfe Rechnungsstatus +$facture = new Facture($db); +$facture->fetch($facture_id); + +if ($force && $facture->statut != Facture::STATUS_DRAFT) { + echo json_encode(['success' => false, 'error' => 'Rechnung ist nicht im Entwurf']); + exit; +} + +// 3. Hole Produkt-IDs DIREKT aus DB +$product_ids = []; +$sql_products = "SELECT fk_facturedet FROM ".MAIN_DB_PREFIX."facture_lines_manager"; +$sql_products .= " WHERE parent_section = ".(int)$section_id; +$sql_products .= " AND line_type = 'product'"; +$res_products = $db->query($sql_products); + +while ($prod = $db->fetch_object($res_products)) { + $product_ids[] = (int)$prod->fk_facturedet; +} + +$product_count = count($product_ids); +subtotaltitle_debug_log('🔍 Gefundene Produkte in Section: ' . implode(', ', $product_ids)); + +$db->begin(); + +// 4. Force-Delete: Produkte aus Rechnung löschen +if ($force && $product_count > 0) { + subtotaltitle_debug_log('🗑️ Lösche ' . $product_count . ' Zeilen aus Rechnung...'); + + foreach ($product_ids as $line_id) { + $sql_del_line = "DELETE FROM ".MAIN_DB_PREFIX."facturedet WHERE rowid = ".(int)$line_id; + $res_del = $db->query($sql_del_line); + + if ($res_del) { + subtotaltitle_debug_log('✅ facturedet gelöscht: ' . $line_id); + } else { + subtotaltitle_debug_log('❌ SQL Fehler: ' . $line_id . ' - ' . $db->lasterror()); + } + } + + // Aus Manager-Tabelle löschen + $sql_del = "DELETE FROM ".MAIN_DB_PREFIX."facture_lines_manager"; + $sql_del .= " WHERE parent_section = ".(int)$section_id; + $sql_del .= " AND line_type = 'product'"; + $db->query($sql_del); + + subtotaltitle_debug_log('🔴 Force-Delete abgeschlossen: ' . $product_count . ' Produkte'); + +} else if (!$force) { + // Ohne force: Produkte nur freigeben + $sql = "UPDATE ".MAIN_DB_PREFIX."facture_lines_manager"; + $sql .= " SET parent_section = NULL"; + $sql .= " WHERE parent_section = ".(int)$section_id; + $sql .= " AND line_type = 'product'"; + $db->query($sql); + + subtotaltitle_debug_log('🔓 ' . $product_count . ' Produkte freigegeben'); +} + +// ========== NEU: SUBTOTAL LÖSCHEN ========== +// Hole Subtotal dieser Section (falls vorhanden) +$sql_subtotal = "SELECT rowid, fk_facturedet FROM ".MAIN_DB_PREFIX."facture_lines_manager"; +$sql_subtotal .= " WHERE parent_section = ".(int)$section_id; +$sql_subtotal .= " AND line_type = 'subtotal'"; +$res_subtotal = $db->query($sql_subtotal); + +if ($obj_sub = $db->fetch_object($res_subtotal)) { + // Falls Subtotal in facturedet ist, dort auch löschen + if ($obj_sub->fk_facturedet > 0) { + $sql_del_fd = "DELETE FROM ".MAIN_DB_PREFIX."facturedet WHERE rowid = ".(int)$obj_sub->fk_facturedet; + $db->query($sql_del_fd); + subtotaltitle_debug_log('✅ Subtotal aus facturedet gelöscht: ' . $obj_sub->fk_facturedet); + } + + // Aus Manager-Tabelle löschen + $sql_del_sub = "DELETE FROM ".MAIN_DB_PREFIX."facture_lines_manager WHERE rowid = ".(int)$obj_sub->rowid; + $db->query($sql_del_sub); + subtotaltitle_debug_log('✅ Subtotal aus Manager gelöscht: ' . $obj_sub->rowid); +} + +// ========== VERWAISTE SUBTOTALS AUFRÄUMEN ========== +// Finde alle Subtotals in dieser Rechnung, deren parent_section nicht mehr existiert +$sql_orphans = "SELECT s.rowid, s.fk_facturedet, s.parent_section + FROM ".MAIN_DB_PREFIX."facture_lines_manager s + WHERE s.fk_facture = ".(int)$facture_id." + AND s.line_type = 'subtotal' + AND s.parent_section IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM ".MAIN_DB_PREFIX."facture_lines_manager sec + WHERE sec.rowid = s.parent_section + AND sec.line_type = 'section' + )"; +$res_orphans = $db->query($sql_orphans); + +$orphan_count = 0; +while ($orphan = $db->fetch_object($res_orphans)) { + // Aus facturedet löschen (falls vorhanden) + if ($orphan->fk_facturedet > 0) { + $sql_del_orphan_fd = "DELETE FROM ".MAIN_DB_PREFIX."facturedet WHERE rowid = ".(int)$orphan->fk_facturedet; + $db->query($sql_del_orphan_fd); + subtotaltitle_debug_log('🧹 Verwaistes Subtotal aus facturedet gelöscht: ' . $orphan->fk_facturedet . ' (parent_section=' . $orphan->parent_section . ')'); + } + + // Aus Manager-Tabelle löschen + $sql_del_orphan = "DELETE FROM ".MAIN_DB_PREFIX."facture_lines_manager WHERE rowid = ".(int)$orphan->rowid; + $db->query($sql_del_orphan); + subtotaltitle_debug_log('🧹 Verwaistes Subtotal aus Manager gelöscht: ' . $orphan->rowid); + $orphan_count++; +} + +if ($orphan_count > 0) { + subtotaltitle_debug_log('🧹 Aufgeräumt: ' . $orphan_count . ' verwaiste Subtotals entfernt'); +} +// ========== ENDE VERWAISTE SUBTOTALS ========== + +// 5. Section selbst löschen +$sql = "DELETE FROM ".MAIN_DB_PREFIX."facture_lines_manager"; +$sql .= " WHERE rowid = ".(int)$section_id; +$db->query($sql); + +// Rechnungstotale neu berechnen (nach allen Löschungen) +$facture->update_price(1); + +// 6. Neuordnen +$sql = "SELECT rowid FROM ".MAIN_DB_PREFIX."facture_lines_manager"; +$sql .= " WHERE fk_facture = ".(int)$facture_id; +$sql .= " ORDER BY line_order"; +$resql = $db->query($sql); + +$new_order = 1; +while ($obj = $db->fetch_object($resql)) { + $sql_upd = "UPDATE ".MAIN_DB_PREFIX."facture_lines_manager"; + $sql_upd .= " SET line_order = ".$new_order; + $sql_upd .= " WHERE rowid = ".(int)$obj->rowid; + $db->query($sql_upd); + $new_order++; +} + +// 7. Sync rang +$sql = "SELECT fk_facturedet FROM ".MAIN_DB_PREFIX."facture_lines_manager"; +$sql .= " WHERE fk_facture = ".(int)$facture_id; +$sql .= " AND line_type = 'product'"; +$sql .= " ORDER BY line_order"; +$resql = $db->query($sql); + +$rang = 1; +while ($obj = $db->fetch_object($resql)) { + $sql_upd = "UPDATE ".MAIN_DB_PREFIX."facturedet"; + $sql_upd .= " SET rang = ".$rang; + $sql_upd .= " WHERE rowid = ".(int)$obj->fk_facturedet; + $db->query($sql_upd); + $rang++; +} + +$db->commit(); + +echo json_encode(['success' => true, 'deleted' => $force ? $product_count : 0]); \ No newline at end of file diff --git a/ajax/delete_textline.php b/ajax/delete_textline.php new file mode 100755 index 0000000..19e49ae --- /dev/null +++ b/ajax/delete_textline.php @@ -0,0 +1,59 @@ + false, 'error' => 'Missing parameters')); + exit; +} + +// 1. Hole facture_id BEVOR wir löschen +$sql = "SELECT fk_facture FROM ".MAIN_DB_PREFIX."facture_lines_manager"; +$sql .= " WHERE rowid = ".(int)$textline_id; +$resql = $db->query($sql); + +if (!$resql || $db->num_rows($resql) == 0) { + echo json_encode(array('success' => false, 'error' => 'Textline not found')); + exit; +} + +$obj = $db->fetch_object($resql); +$facture_id = $obj->fk_facture; + +// 2. DELETE ausführen +$sql = "DELETE FROM ".MAIN_DB_PREFIX."facture_lines_manager"; +$sql .= " WHERE rowid = ".(int)$textline_id; +$sql .= " AND line_type = 'text'"; + +if (!$db->query($sql)) { + echo json_encode(array('success' => false, 'error' => $db->lasterror())); + exit; +} + +// 3. Lücken schließen +$sql = "SELECT rowid FROM ".MAIN_DB_PREFIX."facture_lines_manager + WHERE fk_facture = ".(int)$facture_id." + ORDER BY line_order"; +$resql = $db->query($sql); + +$new_order = 1; +while ($obj = $db->fetch_object($resql)) { + $sql_upd = "UPDATE ".MAIN_DB_PREFIX."facture_lines_manager + SET line_order = ".$new_order." + WHERE rowid = ".(int)$obj->rowid; + $db->query($sql_upd); + $new_order++; +} + +echo json_encode(array('success' => true)); \ No newline at end of file diff --git a/ajax/edit_textline.php b/ajax/edit_textline.php new file mode 100755 index 0000000..31846ae --- /dev/null +++ b/ajax/edit_textline.php @@ -0,0 +1,52 @@ + false, 'error' => 'Missing parameters')); + exit; +} + +// Hole erst fk_facturedet (falls Textzeile in Rechnung ist) +$sql_get = "SELECT fk_facturedet FROM ".MAIN_DB_PREFIX."facture_lines_manager"; +$sql_get .= " WHERE rowid = ".(int)$textline_id; +$sql_get .= " AND line_type = 'text'"; +$resql = $db->query($sql_get); +$obj = $db->fetch_object($resql); +$fk_facturedet = $obj ? $obj->fk_facturedet : null; + +// Update Manager-Tabelle +$sql = "UPDATE ".MAIN_DB_PREFIX."facture_lines_manager"; +$sql .= " SET title = '".$db->escape($text)."'"; +$sql .= " WHERE rowid = ".(int)$textline_id; +$sql .= " AND line_type = 'text'"; + +if (!$db->query($sql)) { + echo json_encode(array('success' => false, 'error' => $db->lasterror())); + exit; +} + +// Falls in facturedet vorhanden, dort auch updaten +if ($fk_facturedet > 0) { + $sql_fd = "UPDATE ".MAIN_DB_PREFIX."facturedet"; + $sql_fd .= " SET description = '".$db->escape($text)."'"; + $sql_fd .= " WHERE rowid = ".(int)$fk_facturedet; + $db->query($sql_fd); + subtotaltitle_debug_log('✅ Textzeile + facturedet geändert'); +} else { + subtotaltitle_debug_log('✅ Textzeile geändert (nicht in facturedet)'); +} + +echo json_encode(array('success' => true, 'synced_facturedet' => ($fk_facturedet > 0))); diff --git a/ajax/get_line_orders.php b/ajax/get_line_orders.php new file mode 100755 index 0000000..f114a6d --- /dev/null +++ b/ajax/get_line_orders.php @@ -0,0 +1,29 @@ + false)); + exit; +} + +$sql = "SELECT fk_facturedet, line_order"; +$sql .= " FROM ".MAIN_DB_PREFIX."facture_lines_manager"; +$sql .= " WHERE fk_facture = ".(int)$facture_id; +$sql .= " AND line_type = 'product'"; +$resql = $db->query($sql); + +$lines = array(); +while ($obj = $db->fetch_object($resql)) { + $lines[] = array( + 'fk_facturedet' => $obj->fk_facturedet, + 'line_order' => $obj->line_order + ); +} + +echo json_encode(array('success' => true, 'lines' => $lines)); diff --git a/ajax/get_sections.php b/ajax/get_sections.php new file mode 100755 index 0000000..f036393 --- /dev/null +++ b/ajax/get_sections.php @@ -0,0 +1,43 @@ + false, 'error' => 'Missing parameters')); + exit; +} + +// Hole die richtigen Tabellennamen für diesen Dokumenttyp +$tables = DocumentTypeHelper::getTableNames($docType); +if (!$tables) { + echo json_encode(array('success' => false, 'error' => 'Invalid document type')); + exit; +} + +// Hole ALLE Sections für diesen Dokumenttyp +$sql = "SELECT s.rowid, s.title, s.line_order, "; +$sql .= " (SELECT COUNT(*) FROM ".MAIN_DB_PREFIX."facture_lines_manager p WHERE p.parent_section = s.rowid) as product_count"; +$sql .= " FROM ".MAIN_DB_PREFIX."facture_lines_manager s"; +$sql .= " WHERE s.".$tables['fk_parent']." = ".(int)$facture_id; +$sql .= " AND s.document_type = '".$db->escape($docType)."'"; +$sql .= " AND s.line_type = 'section'"; +$sql .= " ORDER BY s.line_order"; +$resql = $db->query($sql); + +$sections = array(); +while ($obj = $db->fetch_object($resql)) { + $sections[] = array( + 'id' => $obj->rowid, + 'title' => $obj->title, + 'line_order' => $obj->line_order, + 'is_empty' => ($obj->product_count == 0) + ); +} + +echo json_encode(array('success' => true, 'sections' => $sections)); diff --git a/ajax/get_textlines.php b/ajax/get_textlines.php new file mode 100755 index 0000000..53ad1d4 --- /dev/null +++ b/ajax/get_textlines.php @@ -0,0 +1,45 @@ + false, 'error' => 'Missing parameters')); + exit; +} + +// Hole die richtigen Tabellennamen für diesen Dokumenttyp +$tables = DocumentTypeHelper::getTableNames($docType); +if (!$tables) { + echo json_encode(array('success' => false, 'error' => 'Invalid document type')); + exit; +} + +$sql = "SELECT rowid, title, line_order, parent_section FROM ".MAIN_DB_PREFIX."facture_lines_manager"; +$sql .= " WHERE ".$tables['fk_parent']." = ".(int)$facture_id; +$sql .= " AND document_type = '".$db->escape($docType)."'"; +$sql .= " AND line_type = 'text'"; +$sql .= " ORDER BY line_order"; +$resql = $db->query($sql); + +$textlines = array(); +while ($obj = $db->fetch_object($resql)) { + $textlines[] = array( + 'id' => $obj->rowid, + 'title' => $obj->title, + 'line_order' => $obj->line_order, + 'parent_section' => $obj->parent_section + ); +} + +echo json_encode(array('success' => true, 'textlines' => $textlines)); diff --git a/ajax/mass_delete.php b/ajax/mass_delete.php new file mode 100755 index 0000000..691e9c2 --- /dev/null +++ b/ajax/mass_delete.php @@ -0,0 +1,80 @@ + false, 'error' => 'Missing parameters']); + exit; +} + +// Prüfe Rechnungsstatus +$facture = new Facture($db); +$facture->fetch($facture_id); + +if ($facture->statut != Facture::STATUS_DRAFT) { + echo json_encode(['success' => false, 'error' => 'Rechnung ist nicht im Entwurf']); + exit; +} + +subtotaltitle_debug_log('🗑️ Massenlöschung: ' . count($line_ids) . ' Zeilen'); + +$db->begin(); + +$deleted = 0; +foreach ($line_ids as $line_id) { + $line_id = (int)$line_id; + + // Aus facturedet löschen + $sql = "DELETE FROM ".MAIN_DB_PREFIX."facturedet WHERE rowid = ".$line_id; + if ($db->query($sql)) { + $deleted++; + subtotaltitle_debug_log('✅ Zeile gelöscht: ' . $line_id); + } + + // Aus Manager-Tabelle löschen + $sql_manager = "DELETE FROM ".MAIN_DB_PREFIX."facture_lines_manager WHERE fk_facturedet = ".$line_id; + $db->query($sql_manager); +} + +// Summen neu berechnen +$facture->update_price(1); + +// line_order neu durchnummerieren +$sql = "SELECT rowid FROM ".MAIN_DB_PREFIX."facture_lines_manager"; +$sql .= " WHERE fk_facture = ".(int)$facture_id; +$sql .= " ORDER BY line_order"; +$resql = $db->query($sql); + +$new_order = 1; +while ($obj = $db->fetch_object($resql)) { + $sql_upd = "UPDATE ".MAIN_DB_PREFIX."facture_lines_manager SET line_order = ".$new_order." WHERE rowid = ".(int)$obj->rowid; + $db->query($sql_upd); + $new_order++; +} + +// rang synchronisieren +$sql = "SELECT fk_facturedet FROM ".MAIN_DB_PREFIX."facture_lines_manager"; +$sql .= " WHERE fk_facture = ".(int)$facture_id; +$sql .= " AND line_type = 'product'"; +$sql .= " ORDER BY line_order"; +$resql = $db->query($sql); + +$rang = 1; +while ($obj = $db->fetch_object($resql)) { + $sql_upd = "UPDATE ".MAIN_DB_PREFIX."facturedet SET rang = ".$rang." WHERE rowid = ".(int)$obj->fk_facturedet; + $db->query($sql_upd); + $rang++; +} + +$db->commit(); + +subtotaltitle_debug_log('🗑️ Massenlöschung abgeschlossen: ' . $deleted . ' von ' . count($line_ids)); + +echo json_encode(['success' => true, 'deleted' => $deleted]); \ No newline at end of file diff --git a/ajax/move_product.php b/ajax/move_product.php new file mode 100755 index 0000000..b589c57 --- /dev/null +++ b/ajax/move_product.php @@ -0,0 +1,69 @@ + false, 'error' => 'Missing product_id')); + exit; +} + +$db->begin(); + +// Hole Facture ID +$sql = "SELECT m.fk_facture, m.rowid as manager_id"; +$sql .= " FROM ".MAIN_DB_PREFIX."facture_lines_manager m"; +$sql .= " WHERE m.fk_facturedet = ".(int)$product_id; +$resql = $db->query($sql); + +if (!$resql || $db->num_rows($resql) == 0) { + echo json_encode(array('success' => false, 'error' => 'Product not found')); + exit; +} + +$obj = $db->fetch_object($resql); +$facture_id = $obj->fk_facture; +$manager_id = $obj->rowid; + +// Update parent_section UND line_order +if ($new_section_id > 0) { + $sql = "UPDATE ".MAIN_DB_PREFIX."facture_lines_manager"; + $sql .= " SET parent_section = ".(int)$new_section_id; + if ($new_line_order !== null) { + $sql .= ", line_order = ".(float)$new_line_order; // ← NEU! + } + $sql .= " WHERE rowid = ".(int)$manager_id; +} else { + $sql = "UPDATE ".MAIN_DB_PREFIX."facture_lines_manager"; + $sql .= " SET parent_section = NULL"; + if ($new_line_order !== null) { + $sql .= ", line_order = ".(float)$new_line_order; // ← NEU! + } + $sql .= " WHERE rowid = ".(int)$manager_id; +} + +$db->query($sql); +subtotaltitle_debug_log('✅ parent_section und line_order gesetzt'); + +// Neuordnen (bereinigt Dezimalzahlen und Lücken) +require_once DOL_DOCUMENT_ROOT.'/custom/subtotaltitle/class/actions_subtotaltitle.class.php'; +$hook = new ActionsSubtotalTitle($db); + +$reflection = new ReflectionClass($hook); +$method = $reflection->getMethod('reorderLines'); +$method->setAccessible(true); +$method->invoke($hook, $facture_id); + +$method = $reflection->getMethod('syncRangFromManager'); +$method->setAccessible(true); +$method->invoke($hook, $facture_id); + +$db->commit(); + +echo json_encode(array('success' => true)); diff --git a/ajax/move_section.php b/ajax/move_section.php new file mode 100755 index 0000000..976a97e --- /dev/null +++ b/ajax/move_section.php @@ -0,0 +1,169 @@ + false, 'error' => 'Missing parameters')); + exit; +} + +// Hole Section-Info +$sql = "SELECT fk_facture FROM ".MAIN_DB_PREFIX."facture_lines_manager"; +$sql .= " WHERE rowid = ".(int)$section_id; +$sql .= " AND line_type = 'section'"; +$resql = $db->query($sql); + +if (!$resql || $db->num_rows($resql) == 0) { + echo json_encode(array('success' => false, 'error' => 'Section not found')); + exit; +} + +$section = $db->fetch_object($resql); +$facture_id = $section->fk_facture; + +$db->begin(); + +// 1. Hole alle Sections (sortiert) +$sql = "SELECT rowid FROM ".MAIN_DB_PREFIX."facture_lines_manager"; +$sql .= " WHERE fk_facture = ".(int)$facture_id; +$sql .= " AND line_type = 'section'"; +$sql .= " ORDER BY line_order"; +$resql = $db->query($sql); + +$sections = array(); +while ($obj = $db->fetch_object($resql)) { + $sections[] = $obj->rowid; +} + +// 2. Finde Index und tausche +$current_index = array_search($section_id, $sections); + +if ($current_index === false) { + echo json_encode(array('success' => false, 'error' => 'Section not in list')); + exit; +} + +if ($direction == 'up') { + if ($current_index == 0) { + echo json_encode(array('success' => false, 'error' => 'Already at top')); + exit; + } + $swap_index = $current_index - 1; +} else { + if ($current_index == count($sections) - 1) { + echo json_encode(array('success' => false, 'error' => 'Already at bottom')); + exit; + } + $swap_index = $current_index + 1; +} + +// Tausche +$temp = $sections[$current_index]; +$sections[$current_index] = $sections[$swap_index]; +$sections[$swap_index] = $temp; + +// 3. Baue komplette neue Reihenfolge auf +$new_order = 1; +$updates = array(); + +// Freie Produkte zuerst +$sql = "SELECT rowid FROM ".MAIN_DB_PREFIX."facture_lines_manager"; +$sql .= " WHERE fk_facture = ".(int)$facture_id; +$sql .= " AND line_type = 'product'"; +$sql .= " AND parent_section IS NULL"; +$sql .= " ORDER BY line_order"; +$resql = $db->query($sql); + +while ($obj = $db->fetch_object($resql)) { + $updates[$obj->rowid] = $new_order; + $new_order++; +} + +// Freie Textzeilen +$sql = "SELECT rowid FROM ".MAIN_DB_PREFIX."facture_lines_manager"; +$sql .= " WHERE fk_facture = ".(int)$facture_id; +$sql .= " AND line_type = 'text'"; +$sql .= " AND parent_section IS NULL"; +$sql .= " ORDER BY line_order"; +$resql = $db->query($sql); + +while ($obj = $db->fetch_object($resql)) { + $updates[$obj->rowid] = $new_order; + $new_order++; +} + +// Sections in neuer Reihenfolge +foreach ($sections as $sec_id) { + // Section-Header + $updates[$sec_id] = $new_order; + $new_order++; + + // Produkte dieser Section + $sql = "SELECT rowid FROM ".MAIN_DB_PREFIX."facture_lines_manager"; + $sql .= " WHERE fk_facture = ".(int)$facture_id; + $sql .= " AND line_type = 'product'"; + $sql .= " AND parent_section = ".(int)$sec_id; + $sql .= " ORDER BY line_order"; + $resql = $db->query($sql); + + while ($obj = $db->fetch_object($resql)) { + $updates[$obj->rowid] = $new_order; + $new_order++; + } + + // Textzeilen dieser Section + $sql = "SELECT rowid FROM ".MAIN_DB_PREFIX."facture_lines_manager"; + $sql .= " WHERE fk_facture = ".(int)$facture_id; + $sql .= " AND line_type = 'text'"; + $sql .= " AND parent_section = ".(int)$sec_id; + $sql .= " ORDER BY line_order"; + $resql = $db->query($sql); + + while ($obj = $db->fetch_object($resql)) { + $updates[$obj->rowid] = $new_order; + $new_order++; + } + + // ========== SUBTOTAL DIESER SECTION ========== + $sql = "SELECT rowid FROM ".MAIN_DB_PREFIX."facture_lines_manager"; + $sql .= " WHERE fk_facture = ".(int)$facture_id; + $sql .= " AND line_type = 'subtotal'"; + $sql .= " AND parent_section = ".(int)$sec_id; + $resql = $db->query($sql); + + while ($obj = $db->fetch_object($resql)) { + $updates[$obj->rowid] = $new_order; + $new_order++; + } +} + +// 4. Führe alle Updates aus +foreach ($updates as $rowid => $order) { + $sql = "UPDATE ".MAIN_DB_PREFIX."facture_lines_manager"; + $sql .= " SET line_order = ".(int)$order; + $sql .= " WHERE rowid = ".(int)$rowid; + $db->query($sql); +} + +// 5. Sync rang +$sql = "SELECT fk_facturedet FROM ".MAIN_DB_PREFIX."facture_lines_manager"; +$sql .= " WHERE fk_facture = ".(int)$facture_id; +$sql .= " AND line_type = 'product'"; +$sql .= " ORDER BY line_order"; +$resql = $db->query($sql); + +$rang = 1; +while ($obj = $db->fetch_object($resql)) { + $sql_upd = "UPDATE ".MAIN_DB_PREFIX."facturedet"; + $sql_upd .= " SET rang = ".$rang; + $sql_upd .= " WHERE rowid = ".(int)$obj->fk_facturedet; + $db->query($sql_upd); + $rang++; +} + +$db->commit(); + +echo json_encode(array('success' => true)); \ No newline at end of file diff --git a/ajax/remove_from_section.php b/ajax/remove_from_section.php new file mode 100755 index 0000000..67383c0 --- /dev/null +++ b/ajax/remove_from_section.php @@ -0,0 +1,25 @@ + false, 'error' => 'Missing product_id']); + exit; +} + +$sql = "UPDATE ".MAIN_DB_PREFIX."facture_lines_manager"; +$sql .= " SET parent_section = NULL"; +$sql .= " WHERE fk_facturedet = ".(int)$product_id; + +$result = $db->query($sql); + +if ($result) { + echo json_encode(['success' => true]); +} else { + echo json_encode(['success' => false, 'error' => $db->lasterror()]); +} \ No newline at end of file diff --git a/ajax/rename_section.php b/ajax/rename_section.php new file mode 100755 index 0000000..aca0902 --- /dev/null +++ b/ajax/rename_section.php @@ -0,0 +1,53 @@ + false, 'error' => 'Missing parameters')); + exit; +} + +// Hole erst fk_facturedet (falls Section in Rechnung ist) +$sql_get = "SELECT fk_facturedet FROM ".MAIN_DB_PREFIX."facture_lines_manager"; +$sql_get .= " WHERE rowid = ".(int)$section_id; +$sql_get .= " AND line_type = 'section'"; +$resql = $db->query($sql_get); +$obj = $db->fetch_object($resql); +$fk_facturedet = $obj ? $obj->fk_facturedet : null; + +// Update Manager-Tabelle +$sql = "UPDATE ".MAIN_DB_PREFIX."facture_lines_manager"; +$sql .= " SET title = '".$db->escape($title)."'"; +$sql .= " WHERE rowid = ".(int)$section_id; +$sql .= " AND line_type = 'section'"; + +if (!$db->query($sql)) { + subtotaltitle_debug_log('❌ Fehler: ' . $db->lasterror()); + echo json_encode(array('success' => false, 'error' => $db->lasterror())); + exit; +} + +// Falls in facturedet vorhanden, dort auch updaten +if ($fk_facturedet > 0) { + $sql_fd = "UPDATE ".MAIN_DB_PREFIX."facturedet"; + $sql_fd .= " SET description = '".$db->escape($title)."'"; + $sql_fd .= " WHERE rowid = ".(int)$fk_facturedet; + $db->query($sql_fd); + subtotaltitle_debug_log('✅ Section + facturedet umbenannt'); +} else { + subtotaltitle_debug_log('✅ Section umbenannt (nicht in facturedet)'); +} + +echo json_encode(array('success' => true, 'synced_facturedet' => ($fk_facturedet > 0))); diff --git a/ajax/reorder_all.php b/ajax/reorder_all.php new file mode 100755 index 0000000..982dd1d --- /dev/null +++ b/ajax/reorder_all.php @@ -0,0 +1,132 @@ + false, 'error' => 'Missing parameters']); + exit; +} + +// Hole die richtigen Tabellennamen für diesen Dokumenttyp +$tables = DocumentTypeHelper::getTableNames($docType); +if (!$tables) { + echo json_encode(['success' => false, 'error' => 'Invalid document type']); + exit; +} + +$new_order = json_decode($new_order_json, true); + +if (!$new_order) { + echo json_encode(['success' => false, 'error' => 'Invalid JSON']); + exit; +} + +$db->begin(); + +// Für jede Zeile: line_order und parent_section updaten +foreach ($new_order as $item) { + if ($item['type'] == 'section') { + $sql = "UPDATE ".MAIN_DB_PREFIX."facture_lines_manager"; + $sql .= " SET line_order = ".(int)$item['order']; + $sql .= " WHERE rowid = ".(int)$item['id']; + $db->query($sql); + subtotaltitle_debug_log(' Section #'.$item['id'].' → order='.$item['order']); + + } else if ($item['type'] == 'product') { + $sql = "UPDATE ".MAIN_DB_PREFIX."facture_lines_manager"; + $sql .= " SET line_order = ".(int)$item['order']; + $sql .= ", parent_section = ".($item['parent_section'] ? (int)$item['parent_section'] : "NULL"); + $sql .= " WHERE ".$tables['fk_line']." = ".(int)$item['id']; + $db->query($sql); + subtotaltitle_debug_log(' Produkt #'.$item['id'].' → order='.$item['order'].', section='.($item['parent_section'] ?: 'FREI')); + + } else if ($item['type'] == 'text') { + $sql = "UPDATE ".MAIN_DB_PREFIX."facture_lines_manager"; + $sql .= " SET line_order = ".(int)$item['order']; + $sql .= ", parent_section = ".($item['parent_section'] ? (int)$item['parent_section'] : "NULL"); + $sql .= " WHERE rowid = ".(int)$item['id']; + $sql .= " AND line_type = 'text'"; + $db->query($sql); + subtotaltitle_debug_log(' Text #'.$item['id'].' → order='.$item['order'].', section='.($item['parent_section'] ?: 'FREI')); + } +} + +// ========== SUBTOTALS NEU POSITIONIEREN ========== +subtotaltitle_debug_log('🔢 Repositioniere Subtotals...'); + +$sql = "SELECT rowid, parent_section FROM ".MAIN_DB_PREFIX."facture_lines_manager + WHERE ".$tables['fk_parent']." = ".(int)$facture_id." + AND document_type = '".$db->escape($docType)."' + AND line_type = 'subtotal'"; +$resql = $db->query($sql); + +while ($subtotal = $db->fetch_object($resql)) { + // Finde höchste line_order der Produkte dieser Section + $sql_max = "SELECT MAX(line_order) as max_order + FROM ".MAIN_DB_PREFIX."facture_lines_manager + WHERE parent_section = ".(int)$subtotal->parent_section." + AND line_type = 'product'"; + $res_max = $db->query($sql_max); + $obj_max = $db->fetch_object($res_max); + + if ($obj_max && $obj_max->max_order) { + // Subtotal bekommt hohe Nummer (wird gleich normalisiert) + $temp_order = (int)$obj_max->max_order * 100 + 50; + + $sql_upd = "UPDATE ".MAIN_DB_PREFIX."facture_lines_manager + SET line_order = ".$temp_order." + WHERE rowid = ".(int)$subtotal->rowid; + $db->query($sql_upd); + subtotaltitle_debug_log(' Subtotal #'.$subtotal->rowid.' → temp_order='.$temp_order.' (nach Section '.$subtotal->parent_section.')'); + } +} + +// ========== ALLES NEU DURCHNUMMERIEREN ========== +subtotaltitle_debug_log('🔢 Normalisiere line_order...'); + +$sql = "SELECT rowid FROM ".MAIN_DB_PREFIX."facture_lines_manager + WHERE ".$tables['fk_parent']." = ".(int)$facture_id." + AND document_type = '".$db->escape($docType)."' + ORDER BY line_order"; +$resql = $db->query($sql); + +$new_order_num = 1; +while ($obj = $db->fetch_object($resql)) { + $sql_upd = "UPDATE ".MAIN_DB_PREFIX."facture_lines_manager + SET line_order = ".$new_order_num." + WHERE rowid = ".(int)$obj->rowid; + $db->query($sql_upd); + $new_order_num++; +} + +// ========== SYNC RANG IN DETAIL-TABELLE ========== +// Synchronisiere ALLE Zeilen die in der Detail-Tabelle sind (nicht nur Produkte!) +$sql = "SELECT ".$tables['fk_line'].", line_type FROM ".MAIN_DB_PREFIX."facture_lines_manager"; +$sql .= " WHERE ".$tables['fk_parent']." = ".(int)$facture_id; +$sql .= " AND document_type = '".$db->escape($docType)."'"; +$sql .= " AND ".$tables['fk_line']." IS NOT NULL"; // Alle die in Detail-Tabelle sind +$sql .= " ORDER BY line_order"; +$resql = $db->query($sql); + +$rang = 1; +while ($obj = $db->fetch_object($resql)) { + $fk_line_value = $obj->{$tables['fk_line']}; + $sql_upd = "UPDATE ".MAIN_DB_PREFIX.$tables['lines_table']; + $sql_upd .= " SET rang = ".$rang; + $sql_upd .= " WHERE rowid = ".(int)$fk_line_value; + $db->query($sql_upd); + subtotaltitle_debug_log(' Sync rang: '.$obj->line_type.' #'.$fk_line_value.' → rang='.$rang); + $rang++; +} + +$db->commit(); + +echo json_encode(['success' => true, 'updated' => count($new_order)]); \ No newline at end of file diff --git a/ajax/reorder_invoice.php b/ajax/reorder_invoice.php new file mode 100755 index 0000000..8e87df1 --- /dev/null +++ b/ajax/reorder_invoice.php @@ -0,0 +1,64 @@ +query($sql); + +while ($obj = $db->fetch_object($resql)) { + $db->query("UPDATE ".MAIN_DB_PREFIX."facture_lines_manager SET line_order = ".$new_order." WHERE rowid = ".$obj->rowid); + echo "Product ".$obj->rowid." → ".$new_order."
"; + $new_order++; +} + +// 2. Sections + ihre Produkte +$sql = "SELECT rowid FROM ".MAIN_DB_PREFIX."facture_lines_manager"; +$sql .= " WHERE fk_facture = ".(int)$facture_id; +$sql .= " AND line_type = 'section'"; +$sql .= " ORDER BY line_order"; +$resql = $db->query($sql); + +while ($sec = $db->fetch_object($resql)) { + $db->query("UPDATE ".MAIN_DB_PREFIX."facture_lines_manager SET line_order = ".$new_order." WHERE rowid = ".$sec->rowid); + echo "Section ".$sec->rowid." → ".$new_order."
"; + $new_order++; + + // Produkte + $sql2 = "SELECT rowid FROM ".MAIN_DB_PREFIX."facture_lines_manager"; + $sql2 .= " WHERE parent_section = ".$sec->rowid; + $sql2 .= " ORDER BY line_order"; + $resql2 = $db->query($sql2); + + while ($prod = $db->fetch_object($resql2)) { + $db->query("UPDATE ".MAIN_DB_PREFIX."facture_lines_manager SET line_order = ".$new_order." WHERE rowid = ".$prod->rowid); + echo " Product ".$prod->rowid." → ".$new_order."
"; + $new_order++; + } +} + +// 3. Sync rang +$sql = "SELECT fk_facturedet FROM ".MAIN_DB_PREFIX."facture_lines_manager"; +$sql .= " WHERE fk_facture = ".(int)$facture_id; +$sql .= " AND line_type = 'product'"; +$sql .= " ORDER BY line_order"; +$resql = $db->query($sql); + +$rang = 1; +while ($obj = $db->fetch_object($resql)) { + $db->query("UPDATE ".MAIN_DB_PREFIX."facturedet SET rang = ".$rang." WHERE rowid = ".$obj->fk_facturedet); + echo "Rang ".$obj->fk_facturedet." → ".$rang."
"; + $rang++; +} + +echo "
DONE!"; diff --git a/ajax/repair_missing_subtotals.php b/ajax/repair_missing_subtotals.php new file mode 100644 index 0000000..5db05a9 --- /dev/null +++ b/ajax/repair_missing_subtotals.php @@ -0,0 +1,73 @@ + false, 'error' => 'Missing parameters']); + exit; +} + +$tables = DocumentTypeHelper::getTableNames($docType); +if (!$tables) { + echo json_encode(['success' => false, 'error' => 'Invalid document type']); + exit; +} + +$repaired = 0; + +// Finde alle Sections, die keine Subtotal-Zeile haben +$sql = "SELECT s.rowid, s.title"; +$sql .= " FROM ".MAIN_DB_PREFIX."facture_lines_manager s"; +$sql .= " WHERE s.".$tables['fk_parent']." = ".(int)$facture_id; +$sql .= " AND s.document_type = '".$db->escape($docType)."'"; +$sql .= " AND s.line_type = 'section'"; +$sql .= " AND NOT EXISTS ("; +$sql .= " SELECT 1 FROM ".MAIN_DB_PREFIX."facture_lines_manager sub"; +$sql .= " WHERE sub.parent_section = s.rowid"; +$sql .= " AND sub.line_type = 'subtotal'"; +$sql .= " AND sub.document_type = '".$db->escape($docType)."'"; +$sql .= " )"; + +$resql = $db->query($sql); + +while ($section = $db->fetch_object($resql)) { + subtotaltitle_debug_log('🔧 Repariere Section #'.$section->rowid.' ('.$section->title.') - erstelle Subtotal'); + + // Hole nächste line_order + $sql_max = "SELECT MAX(line_order) as max_order FROM ".MAIN_DB_PREFIX."facture_lines_manager"; + $sql_max .= " WHERE ".$tables['fk_parent']." = ".(int)$facture_id; + $sql_max .= " AND document_type = '".$db->escape($docType)."'"; + $res_max = $db->query($sql_max); + $obj_max = $db->fetch_object($res_max); + $next_order = ($obj_max && $obj_max->max_order) ? $obj_max->max_order + 1 : 9999; + + $fk_facture = ($docType === 'invoice') ? (int)$facture_id : 'NULL'; + $fk_propal = ($docType === 'propal') ? (int)$facture_id : 'NULL'; + $fk_commande = ($docType === 'order') ? (int)$facture_id : 'NULL'; + + $sql_ins = "INSERT INTO ".MAIN_DB_PREFIX."facture_lines_manager"; + $sql_ins .= " (fk_facture, fk_propal, fk_commande, document_type, line_type, title, parent_section, line_order, date_creation)"; + $sql_ins .= " VALUES (".$fk_facture.", ".$fk_propal.", ".$fk_commande.", '".$db->escape($docType)."', 'subtotal', 'Zwischensumme', ".(int)$section->rowid.", ".$next_order.", NOW())"; + + if ($db->query($sql_ins)) { + $repaired++; + subtotaltitle_debug_log('✅ Subtotal-Zeile erstellt für Section #'.$section->rowid); + } else { + subtotaltitle_debug_log('❌ Fehler beim Erstellen der Subtotal-Zeile für Section #'.$section->rowid.': '.$db->lasterror()); + } +} + +echo json_encode([ + 'success' => true, + 'repaired' => $repaired, + 'message' => $repaired.' Subtotal-Zeilen erstellt' +]); diff --git a/ajax/sync_to_facturedet.php b/ajax/sync_to_facturedet.php new file mode 100755 index 0000000..6aa4735 --- /dev/null +++ b/ajax/sync_to_facturedet.php @@ -0,0 +1,341 @@ + + * + * Sync SubtotalTitle lines to/from facturedet + */ + +define('NOTOKENRENEWAL', 1); + +$res = 0; +if (!$res && file_exists("../../../main.inc.php")) $res = @include "../../../main.inc.php"; +if (!$res && file_exists("../../../../main.inc.php")) $res = @include "../../../../main.inc.php"; +if (!$res) die("Include of main fails"); + +require_once DOL_DOCUMENT_ROOT.'/compta/facture/class/facture.class.php'; +dol_include_once('/subtotaltitle/lib/subtotaltitle.lib.php'); +require_once __DIR__.'/../class/DocumentTypeHelper.class.php'; + +header('Content-Type: application/json'); + +$action = GETPOST('action', 'alpha'); +$line_id = GETPOST('line_id', 'int'); +$line_type = GETPOST('line_type', 'alpha'); +$facture_id = GETPOST('facture_id', 'int'); +$docType = GETPOST('document_type', 'alpha'); + +subtotaltitle_debug_log('🔄 sync_to_facturedet: action='.$action.', line_id='.$line_id.', type='.$line_type.', docType='.$docType); + +if (!$line_id || !$action || !$docType) { + echo json_encode(array('success' => false, 'error' => 'Missing parameters')); + exit; +} + +// Hole die richtigen Tabellennamen für diesen Dokumenttyp +$tables = DocumentTypeHelper::getTableNames($docType); +if (!$tables) { + echo json_encode(array('success' => false, 'error' => 'Invalid document type')); + exit; +} + +// Special codes für unsere Zeilentypen +$special_codes = array( + 'section' => 100, + 'text' => 101, + 'subtotal' => 102 +); + +if ($action == 'add') { + // ========== ZUR RECHNUNG HINZUFÜGEN ========== + + // Hole Daten aus unserer Manager-Tabelle + $sql = "SELECT m.*, s.title as section_title, s.rowid as section_rowid"; + $sql .= " FROM ".MAIN_DB_PREFIX."facture_lines_manager m"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."facture_lines_manager s ON s.rowid = m.parent_section"; + $sql .= " WHERE m.rowid = ".(int)$line_id; + $resql = $db->query($sql); + + if (!$resql || $db->num_rows($resql) == 0) { + echo json_encode(array('success' => false, 'error' => 'Line not found')); + exit; + } + + $line = $db->fetch_object($resql); + $document_id = $line->{$tables['fk_parent']}; + $line_type = $line->line_type; + $fk_line_field = $tables['fk_line']; + + // Prüfe ob schon in detail-Tabelle (für nicht-Produkte) + if ($line->$fk_line_field > 0 && $line_type != 'product') { + echo json_encode(array('success' => false, 'error' => 'Already in detail table')); + exit; + } + + // AUTOMATISCHE REPARATUR: Wenn Section keine Subtotal-Zeile hat, erstelle sie + if ($line_type == 'section') { + $sql_check = "SELECT rowid FROM ".MAIN_DB_PREFIX."facture_lines_manager"; + $sql_check .= " WHERE parent_section = ".(int)$line_id; + $sql_check .= " AND line_type = 'subtotal'"; + $sql_check .= " AND document_type = '".$db->escape($docType)."'"; + $res_check = $db->query($sql_check); + + if ($db->num_rows($res_check) == 0) { + // Keine Subtotal-Zeile vorhanden - automatisch erstellen + subtotaltitle_debug_log('⚠️ Section #'.$line_id.' hat keine Subtotal-Zeile - erstelle automatisch'); + + $sql_max = "SELECT MAX(line_order) as max_order FROM ".MAIN_DB_PREFIX."facture_lines_manager"; + $sql_max .= " WHERE ".$tables['fk_parent']." = ".(int)$document_id; + $sql_max .= " AND document_type = '".$db->escape($docType)."'"; + $res_max = $db->query($sql_max); + $obj_max = $db->fetch_object($res_max); + $next_order = ($obj_max && $obj_max->max_order) ? $obj_max->max_order + 1 : 9999; + + $fk_facture = ($docType === 'invoice') ? (int)$document_id : 'NULL'; + $fk_propal = ($docType === 'propal') ? (int)$document_id : 'NULL'; + $fk_commande = ($docType === 'order') ? (int)$document_id : 'NULL'; + + $sql_subtotal = "INSERT INTO ".MAIN_DB_PREFIX."facture_lines_manager"; + $sql_subtotal .= " (fk_facture, fk_propal, fk_commande, document_type, line_type, title, parent_section, line_order, date_creation)"; + $sql_subtotal .= " VALUES (".$fk_facture.", ".$fk_propal.", ".$fk_commande.", '".$db->escape($docType)."', 'subtotal', 'Zwischensumme', ".(int)$line_id.", ".$next_order.", NOW())"; + $db->query($sql_subtotal); + + subtotaltitle_debug_log('✅ Subtotal-Zeile automatisch erstellt für Section #'.$line_id); + } + } + + // Bestimme special_code + $special_code = isset($special_codes[$line_type]) ? $special_codes[$line_type] : 0; + + // Bestimme Beschreibung und Betrag + $description = ''; + $total_ht = 0; + $qty = 0; + + switch ($line_type) { + case 'section': + $description = $line->title; + $qty = 0; + break; + + case 'text': + $description = $line->title; + $qty = 0; + break; + + case 'subtotal': + // Berechne Summe der Section + $sql_sum = "SELECT SUM(d.total_ht) as total"; + $sql_sum .= " FROM ".MAIN_DB_PREFIX."facture_lines_manager m"; + $sql_sum .= " INNER JOIN ".MAIN_DB_PREFIX.$tables['lines_table']." d ON d.rowid = m.".$tables['fk_line']; + $sql_sum .= " WHERE m.parent_section = ".(int)$line->parent_section; + $sql_sum .= " AND m.document_type = '".$db->escape($docType)."'"; + $sql_sum .= " AND m.line_type = 'product'"; + $res_sum = $db->query($sql_sum); + $obj_sum = $db->fetch_object($res_sum); + $total_ht = $obj_sum->total ? $obj_sum->total : 0; + + $description = 'Zwischensumme: '.$line->section_title; + $qty = 1; + break; + } + + // Bestimme rang (Position) - UNTERSCHIEDLICH für Sections vs andere Zeilen + $new_rang = 1; + + if ($line_type == 'section') { + // Für Sections: Finde das erste Produkt dieser Section und füge Section DAVOR ein + $sql_first_product = "SELECT MIN(d.rang) as min_rang"; + $sql_first_product .= " FROM ".MAIN_DB_PREFIX."facture_lines_manager m"; + $sql_first_product .= " INNER JOIN ".MAIN_DB_PREFIX.$tables['lines_table']." d ON d.rowid = m.".$tables['fk_line']; + $sql_first_product .= " WHERE m.parent_section = ".(int)$line_id; + $sql_first_product .= " AND m.".$tables['fk_parent']." = ".(int)$document_id; + $sql_first_product .= " AND m.document_type = '".$db->escape($docType)."'"; + $sql_first_product .= " AND m.line_type = 'product'"; + $res_first = $db->query($sql_first_product); + $obj_first = $db->fetch_object($res_first); + + if ($obj_first && $obj_first->min_rang) { + // Section VOR dem ersten Produkt einfügen + $new_rang = (int)$obj_first->min_rang; + } else { + // Keine Produkte in dieser Section - ans Ende + $sql_max = "SELECT MAX(rang) as max_rang FROM ".MAIN_DB_PREFIX.$tables['lines_table']; + $sql_max .= " WHERE ".$tables['fk_parent']." = ".(int)$document_id; + $res_max = $db->query($sql_max); + $obj_max = $db->fetch_object($res_max); + $new_rang = ($obj_max && $obj_max->max_rang) ? $obj_max->max_rang + 1 : 1; + } + } else { + // Für Text/Subtotal: Basierend auf line_order Position + $sql_rang = "SELECT MAX(d.rang) as max_rang"; + $sql_rang .= " FROM ".MAIN_DB_PREFIX."facture_lines_manager m"; + $sql_rang .= " INNER JOIN ".MAIN_DB_PREFIX.$tables['lines_table']." d ON d.rowid = m.".$tables['fk_line']; + $sql_rang .= " WHERE m.".$tables['fk_parent']." = ".(int)$document_id; + $sql_rang .= " AND m.document_type = '".$db->escape($docType)."'"; + $sql_rang .= " AND m.line_order < ".(int)$line->line_order; + $res_rang = $db->query($sql_rang); + $obj_rang = $db->fetch_object($res_rang); + $new_rang = ($obj_rang && $obj_rang->max_rang) ? $obj_rang->max_rang + 1 : 1; + } + + subtotaltitle_debug_log('📝 Berechne rang: line_type='.$line_type.', new_rang='.$new_rang); + + // Verschiebe alle nachfolgenden Zeilen + $sql_shift = "UPDATE ".MAIN_DB_PREFIX.$tables['lines_table']; + $sql_shift .= " SET rang = rang + 1"; + $sql_shift .= " WHERE ".$tables['fk_parent']." = ".(int)$document_id; + $sql_shift .= " AND rang >= ".(int)$new_rang; + $db->query($sql_shift); + + // Füge neue Zeile in Detail-Tabelle ein + subtotaltitle_debug_log('📝 INSERT: line_type='.$line_type.', special_code='.$special_code); + + $sql_ins = "INSERT INTO ".MAIN_DB_PREFIX.$tables['lines_table']; + $sql_ins .= " (".$tables['fk_parent'].", description, qty, subprice, total_ht, total_tva, total_ttc,"; + $sql_ins .= " tva_tx, product_type, special_code, rang, info_bits)"; + $sql_ins .= " VALUES ("; + $sql_ins .= (int)$document_id.", "; + $sql_ins .= "'".$db->escape($description)."', "; + $sql_ins .= (float)$qty.", "; + $sql_ins .= ($line_type == 'subtotal') ? (float)$total_ht.", " : "0, "; + $sql_ins .= ($line_type == 'subtotal') ? (float)$total_ht.", " : "0, "; + $sql_ins .= "0, "; // total_tva + $sql_ins .= ($line_type == 'subtotal') ? (float)$total_ht.", " : "0, "; + $sql_ins .= "0, "; // tva_tx + $sql_ins .= "9, "; // product_type = 9 (Titel/Kommentar) + $sql_ins .= (int)$special_code.", "; + $sql_ins .= (int)$new_rang.", "; + $sql_ins .= "0)"; + + subtotaltitle_debug_log('📝 SQL: '.$sql_ins); + + if (!$db->query($sql_ins)) { + echo json_encode(array('success' => false, 'error' => $db->lasterror())); + exit; + } + + $new_detail_id = $db->last_insert_id(MAIN_DB_PREFIX.$tables['lines_table']); + + // Update unsere Manager-Tabelle + $sql_upd = "UPDATE ".MAIN_DB_PREFIX."facture_lines_manager"; + $sql_upd .= " SET ".$tables['fk_line']." = ".(int)$new_detail_id; + $sql_upd .= ", in_facturedet = 1"; + $sql_upd .= " WHERE rowid = ".(int)$line_id; + $db->query($sql_upd); + + subtotaltitle_debug_log('✅ Zeile #'.$line_id.' zu '.$tables['lines_table'].' hinzugefügt als #'.$new_detail_id); + + echo json_encode(array( + 'success' => true, + 'detail_id' => $new_detail_id, + 'rang' => $new_rang + )); + +} elseif ($action == 'remove') { + // ========== AUS DETAIL-TABELLE ENTFERNEN ========== + + // Hole Daten + $sql = "SELECT ".$tables['fk_line']." as detail_id, ".$tables['fk_parent']." as parent_id, line_type FROM ".MAIN_DB_PREFIX."facture_lines_manager"; + $sql .= " WHERE rowid = ".(int)$line_id; + $resql = $db->query($sql); + + if (!$resql || $db->num_rows($resql) == 0) { + echo json_encode(array('success' => false, 'error' => 'Line not found')); + exit; + } + + $line = $db->fetch_object($resql); + + // Produkte dürfen nicht entfernt werden + if ($line->line_type == 'product') { + echo json_encode(array('success' => false, 'error' => 'Cannot remove products')); + exit; + } + + if (!$line->detail_id) { + echo json_encode(array('success' => false, 'error' => 'Not in detail table')); + exit; + } + + // Hole rang bevor wir löschen + $sql_rang = "SELECT rang FROM ".MAIN_DB_PREFIX.$tables['lines_table']." WHERE rowid = ".(int)$line->detail_id; + $res_rang = $db->query($sql_rang); + $obj_rang = $db->fetch_object($res_rang); + $old_rang = $obj_rang ? $obj_rang->rang : 0; + + // Lösche aus Detail-Tabelle + $sql_del = "DELETE FROM ".MAIN_DB_PREFIX.$tables['lines_table']; + $sql_del .= " WHERE rowid = ".(int)$line->detail_id; + + if (!$db->query($sql_del)) { + echo json_encode(array('success' => false, 'error' => $db->lasterror())); + exit; + } + + // Schließe Lücke in rang + if ($old_rang > 0) { + $sql_shift = "UPDATE ".MAIN_DB_PREFIX.$tables['lines_table']; + $sql_shift .= " SET rang = rang - 1"; + $sql_shift .= " WHERE ".$tables['fk_parent']." = ".(int)$line->parent_id; + $sql_shift .= " AND rang > ".(int)$old_rang; + $db->query($sql_shift); + } + + // Update unsere Manager-Tabelle + $sql_upd = "UPDATE ".MAIN_DB_PREFIX."facture_lines_manager"; + $sql_upd .= " SET ".$tables['fk_line']." = NULL"; + $sql_upd .= ", in_facturedet = 0"; + $sql_upd .= " WHERE rowid = ".(int)$line_id; + $db->query($sql_upd); + + subtotaltitle_debug_log('✅ Zeile #'.$line_id.' aus '.$tables['lines_table'].' entfernt'); + + echo json_encode(array('success' => true)); + +} elseif ($action == 'update_subtotal') { + // ========== SUBTOTAL-BETRAG AKTUALISIEREN ========== + + $sql = "SELECT m.".$tables['fk_line']." as detail_id, m.parent_section, s.title as section_title"; + $sql .= " FROM ".MAIN_DB_PREFIX."facture_lines_manager m"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."facture_lines_manager s ON s.rowid = m.parent_section"; + $sql .= " WHERE m.rowid = ".(int)$line_id; + $sql .= " AND m.line_type = 'subtotal'"; + $resql = $db->query($sql); + + if (!$resql || $db->num_rows($resql) == 0) { + echo json_encode(array('success' => false, 'error' => 'Subtotal not found')); + exit; + } + + $line = $db->fetch_object($resql); + + if (!$line->detail_id) { + echo json_encode(array('success' => false, 'error' => 'Not in detail table')); + exit; + } + + // Berechne neue Summe + $sql_sum = "SELECT SUM(d.total_ht) as total"; + $sql_sum .= " FROM ".MAIN_DB_PREFIX."facture_lines_manager m"; + $sql_sum .= " INNER JOIN ".MAIN_DB_PREFIX.$tables['lines_table']." d ON d.rowid = m.".$tables['fk_line']; + $sql_sum .= " WHERE m.parent_section = ".(int)$line->parent_section; + $sql_sum .= " AND m.document_type = '".$db->escape($docType)."'"; + $sql_sum .= " AND m.line_type = 'product'"; + $res_sum = $db->query($sql_sum); + $obj_sum = $db->fetch_object($res_sum); + $total_ht = $obj_sum->total ? $obj_sum->total : 0; + + // Update Detail-Tabelle + $sql_upd = "UPDATE ".MAIN_DB_PREFIX.$tables['lines_table']; + $sql_upd .= " SET subprice = ".(float)$total_ht; + $sql_upd .= ", total_ht = ".(float)$total_ht; + $sql_upd .= ", total_ttc = ".(float)$total_ht; + $sql_upd .= " WHERE rowid = ".(int)$line->detail_id; + $db->query($sql_upd); + + subtotaltitle_debug_log('✅ Subtotal #'.$line_id.' aktualisiert: '.$total_ht); + + echo json_encode(array('success' => true, 'total_ht' => $total_ht)); + +} else { + echo json_encode(array('success' => false, 'error' => 'Unknown action')); +} diff --git a/ajax/toggle_subtotal.php b/ajax/toggle_subtotal.php new file mode 100755 index 0000000..d4962b8 --- /dev/null +++ b/ajax/toggle_subtotal.php @@ -0,0 +1,184 @@ + false, 'error' => 'Missing parameters']); + exit; +} + +// Hole die richtigen Tabellennamen für diesen Dokumenttyp +$tables = DocumentTypeHelper::getTableNames($docType); +if (!$tables) { + echo json_encode(['success' => false, 'error' => 'Invalid document type']); + exit; +} + +// Hole Section-Info +$sql = "SELECT fk_facture, fk_propal, fk_commande, title, document_type FROM ".MAIN_DB_PREFIX."facture_lines_manager"; +$sql .= " WHERE rowid = ".(int)$section_id; +$resql = $db->query($sql); +$section = $db->fetch_object($resql); +$facture_id = $section->{$tables['fk_parent']}; + +$db->begin(); + +// Update show_subtotal Flag +$sql = "UPDATE ".MAIN_DB_PREFIX."facture_lines_manager"; +$sql .= " SET show_subtotal = ".(int)$show; +$sql .= " WHERE rowid = ".(int)$section_id; +$db->query($sql); + +if ($show) { + // Prüfe ob Subtotal-Zeile schon existiert + $sql_check = "SELECT rowid FROM ".MAIN_DB_PREFIX."facture_lines_manager"; + $sql_check .= " WHERE parent_section = ".(int)$section_id; + $sql_check .= " AND line_type = 'subtotal'"; + $res_check = $db->query($sql_check); + + if ($db->num_rows($res_check) == 0) { + // Berechne Zwischensumme + $sql_sum = "SELECT SUM(d.total_ht) as total"; + $sql_sum .= " FROM ".MAIN_DB_PREFIX."facture_lines_manager m"; + $sql_sum .= " INNER JOIN ".MAIN_DB_PREFIX.$tables['lines_table']." d ON d.rowid = m.".$tables['fk_line']; + $sql_sum .= " WHERE m.parent_section = ".(int)$section_id; + $sql_sum .= " AND m.document_type = '".$db->escape($docType)."'"; + $sql_sum .= " AND m.line_type = 'product'"; + $res_sum = $db->query($sql_sum); + $obj_sum = $db->fetch_object($res_sum); + $subtotal_ht = $obj_sum->total ? (float)$obj_sum->total : 0; + + // Finde line_order des letzten Produkts dieser Section + $sql_last = "SELECT MAX(line_order) as max_order FROM ".MAIN_DB_PREFIX."facture_lines_manager"; + $sql_last .= " WHERE parent_section = ".(int)$section_id; + $sql_last .= " AND line_type = 'product'"; + $res_last = $db->query($sql_last); + $obj_last = $db->fetch_object($res_last); + $last_order = $obj_last->max_order ? $obj_last->max_order : 0; + + // Neue line_order = nach letztem Produkt + $new_order = $last_order + 1; + + // Alle nachfolgenden Zeilen +1 + $sql_shift = "UPDATE ".MAIN_DB_PREFIX."facture_lines_manager"; + $sql_shift .= " SET line_order = line_order + 1"; + $sql_shift .= " WHERE ".$tables['fk_parent']." = ".(int)$facture_id; + $sql_shift .= " AND document_type = '".$db->escape($docType)."'"; + $sql_shift .= " AND line_order >= ".$new_order; + $db->query($sql_shift); + + // Bestimme FK-Felder + $fk_facture = ($docType === 'invoice') ? (int)$facture_id : 'NULL'; + $fk_propal = ($docType === 'propal') ? (int)$facture_id : 'NULL'; + $fk_commande = ($docType === 'order') ? (int)$facture_id : 'NULL'; + + // Subtotal-Zeile in Manager-Tabelle einfügen + $sql_ins = "INSERT INTO ".MAIN_DB_PREFIX."facture_lines_manager"; + $sql_ins .= " (fk_facture, fk_propal, fk_commande, document_type, line_type, parent_section, title, line_order, date_creation)"; + $sql_ins .= " VALUES (".$fk_facture.", ".$fk_propal.", ".$fk_commande.", '".$db->escape($docType)."', 'subtotal', ".(int)$section_id.", 'Zwischensumme: ".addslashes($section->title)."', ".$new_order.", NOW())"; + $db->query($sql_ins); + $subtotal_manager_id = $db->last_insert_id(MAIN_DB_PREFIX."facture_lines_manager"); + + // Subtotal-Zeile auch direkt in Detail-Tabelle einfügen + // Bestimme rang (nach letztem Produkt der Section) + $sql_rang = "SELECT MAX(d.rang) as max_rang"; + $sql_rang .= " FROM ".MAIN_DB_PREFIX."facture_lines_manager m"; + $sql_rang .= " INNER JOIN ".MAIN_DB_PREFIX.$tables['lines_table']." d ON d.rowid = m.".$tables['fk_line']; + $sql_rang .= " WHERE m.parent_section = ".(int)$section_id; + $sql_rang .= " AND m.document_type = '".$db->escape($docType)."'"; + $sql_rang .= " AND m.line_type = 'product'"; + $res_rang = $db->query($sql_rang); + $obj_rang = $db->fetch_object($res_rang); + $new_rang = ($obj_rang && $obj_rang->max_rang) ? $obj_rang->max_rang + 1 : 1; + + // Verschiebe nachfolgende Zeilen + $sql_shift_fd = "UPDATE ".MAIN_DB_PREFIX.$tables['lines_table']; + $sql_shift_fd .= " SET rang = rang + 1"; + $sql_shift_fd .= " WHERE ".$tables['fk_parent']." = ".(int)$facture_id; + $sql_shift_fd .= " AND rang >= ".(int)$new_rang; + $db->query($sql_shift_fd); + + // Füge Subtotal in Detail-Tabelle ein (special_code = 102) + $sql_ins_fd = "INSERT INTO ".MAIN_DB_PREFIX.$tables['lines_table']; + $sql_ins_fd .= " (".$tables['fk_parent'].", description, qty, subprice, total_ht, total_tva, total_ttc,"; + $sql_ins_fd .= " tva_tx, product_type, special_code, rang, info_bits)"; + $sql_ins_fd .= " VALUES ("; + $sql_ins_fd .= (int)$facture_id.", "; + $sql_ins_fd .= "'".$db->escape('Zwischensumme: '.$section->title)."', "; + $sql_ins_fd .= "1, "; // qty + $sql_ins_fd .= (float)$subtotal_ht.", "; // subprice + $sql_ins_fd .= (float)$subtotal_ht.", "; // total_ht + $sql_ins_fd .= "0, "; // total_tva + $sql_ins_fd .= (float)$subtotal_ht.", "; // total_ttc + $sql_ins_fd .= "0, "; // tva_tx + $sql_ins_fd .= "9, "; // product_type = 9 (Titel/Kommentar) + $sql_ins_fd .= "102, "; // special_code = 102 (Subtotal) + $sql_ins_fd .= (int)$new_rang.", "; + $sql_ins_fd .= "0)"; + $db->query($sql_ins_fd); + $new_detail_id = $db->last_insert_id(MAIN_DB_PREFIX.$tables['lines_table']); + + // Verknüpfe Manager-Eintrag mit Detail-Tabelle + $sql_link = "UPDATE ".MAIN_DB_PREFIX."facture_lines_manager"; + $sql_link .= " SET ".$tables['fk_line']." = ".(int)$new_detail_id; + $sql_link .= ", in_facturedet = 1"; + $sql_link .= " WHERE rowid = ".(int)$subtotal_manager_id; + $db->query($sql_link); + + subtotaltitle_debug_log('✅ Subtotal-Zeile erstellt für Section ' . $section_id . ' mit Summe ' . $subtotal_ht . ' ('.$tables['lines_table'].' #' . $new_detail_id . ', special_code=102)'); + } +} else { + // Hole erst fk_facturedet bevor wir löschen + $sql_get = "SELECT fk_facturedet FROM ".MAIN_DB_PREFIX."facture_lines_manager"; + $sql_get .= " WHERE parent_section = ".(int)$section_id; + $sql_get .= " AND line_type = 'subtotal'"; + $res_get = $db->query($sql_get); + $obj_get = $db->fetch_object($res_get); + $fk_facturedet = $obj_get ? $obj_get->fk_facturedet : null; + + // Aus facturedet löschen (falls vorhanden) + if ($fk_facturedet > 0) { + $sql_del_fd = "DELETE FROM ".MAIN_DB_PREFIX."facturedet WHERE rowid = ".(int)$fk_facturedet; + $db->query($sql_del_fd); + subtotaltitle_debug_log('🗑️ Subtotal aus facturedet gelöscht: #' . $fk_facturedet); + } + + // Subtotal-Zeile aus Manager löschen + $sql_del = "DELETE FROM ".MAIN_DB_PREFIX."facture_lines_manager"; + $sql_del .= " WHERE parent_section = ".(int)$section_id; + $sql_del .= " AND line_type = 'subtotal'"; + $db->query($sql_del); + + subtotaltitle_debug_log('🗑️ Subtotal-Zeile gelöscht für Section ' . $section_id); + + // line_order neu durchnummerieren + $sql = "SELECT rowid FROM ".MAIN_DB_PREFIX."facture_lines_manager"; + $sql .= " WHERE fk_facture = ".(int)$facture_id; + $sql .= " ORDER BY line_order"; + $resql = $db->query($sql); + + $new_order = 1; + while ($obj = $db->fetch_object($resql)) { + $sql_upd = "UPDATE ".MAIN_DB_PREFIX."facture_lines_manager"; + $sql_upd .= " SET line_order = ".$new_order; + $sql_upd .= " WHERE rowid = ".(int)$obj->rowid; + $db->query($sql_upd); + $new_order++; + } +} + +$db->commit(); + +echo json_encode([ + 'success' => true, + 'show' => $show, + 'reload' => true +]); \ No newline at end of file diff --git a/build/buildzip.php b/build/buildzip.php new file mode 100755 index 0000000..3508bbb --- /dev/null +++ b/build/buildzip.php @@ -0,0 +1,316 @@ +#!/usr/bin/env php -d memory_limit=256M + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +/* + The goal of that php CLI script is to make zip package of your module + as an alternative to web "build zip" or "perl script makepack" +*/ + +// ============================================= configuration + +/** + * list of files & dirs of your module + * + * @var string[] + */ +$listOfModuleContent = [ + 'admin', + 'ajax', + 'backport', + 'class', + 'css', + 'COPYING', + 'core', + 'img', + 'js', + 'langs', + 'lib', + 'sql', + 'tpl', + '*.md', + '*.json', + '*.php', + 'modulebuilder.txt', +]; + +/** + * if you want to exclude some files from your zip + * + * @var string[] + */ +$exclude_list = [ + '/^.git$/', + '/.*js.map/', + '/DEV.md/' +]; + +// ============================================= end of configuration + +/** + * auto detect module name and version from file name + * + * @return (string|string)[] module name and module version + */ +function detectModule() +{ + $name = $version = ""; + $tab = glob("core/modules/mod*.class.php"); + if (count($tab) == 0) { + echo "[fail] Error on auto detect data : there is no mod*.class.php file into core/modules dir\n"; + exit(-1); + } + if (count($tab) == 1) { + $file = $tab[0]; + $pattern = "/.*mod(?.*)\.class\.php/"; + if (preg_match_all($pattern, $file, $matches)) { + $name = strtolower(reset($matches['mod'])); + } + + echo "extract data from $file\n"; + if (!file_exists($file) || $name == "") { + echo "[fail] Error on auto detect data\n"; + exit(-2); + } + } else { + echo "[fail] Error there is more than one mod*.class.php file into core/modules dir\n"; + exit(-3); + } + + //extract version from file + $contents = file_get_contents($file); + $pattern = "/^.*this->version\s*=\s*'(?.*)'\s*;.*\$/m"; + + // search, and store all matching occurrences in $matches + if (preg_match_all($pattern, $contents, $matches)) { + $version = reset($matches['version']); + } + + if (version_compare($version, '0.0.1', '>=') != 1) { + echo "[fail] Error auto extract version fail\n"; + exit(-4); + } + + echo "module name = $name, version = $version\n"; + return [(string) $name, (string) $version]; +} + +/** + * delete recursively a directory + * + * @param string $dir dir path to delete + * + * @return bool true on success or false on failure. + */ +function delTree($dir) +{ + $files = array_diff(scandir($dir), array('.', '..')); + foreach ($files as $file) { + (is_dir("$dir/$file")) ? delTree("$dir/$file") : secureUnlink("$dir/$file"); + } + return rmdir($dir); +} + + +/** + * do a secure delete file/dir with double check + * (don't trust unlink return) + * + * @param string $path full path to delete + * + * @return bool true on success ($path does not exists at the end of process), else exit + */ +function secureUnlink($path) +{ + if (file_exists($path)) { + if (unlink($path)) { + //then check if really deleted + clearstatcache(); + if (file_exists($path)) { // @phpstan-ignore-line + echo "[fail] unlink of $path fail !\n"; + exit(-5); + } + } else { + echo "[fail] unlink of $path fail !\n"; + exit(-6); + } + } + return true; +} + +/** + * create a directory and check if dir exists + * + * @param string $path path to make + * + * @return bool true on success ($path exists at the end of process), else exit + */ +function mkdirAndCheck($path) +{ + if (mkdir($path)) { + clearstatcache(); + if (is_dir($path)) { + return true; + } + } + echo "[fail] Error on $path (mkdir)\n"; + exit(7); +} + +/** + * check if that filename is concerned by exclude filter + * + * @param string $filename file name to check + * + * @return bool true if file is in excluded list + */ +function is_excluded($filename) +{ + global $exclude_list; + $count = 0; + $notused = preg_filter($exclude_list, '1', $filename, -1, $count); + if ($count > 0) { + echo " - exclude $filename\n"; + return true; + } + return false; +} + +/** + * recursive copy files & dirs + * + * @param string $src source dir + * @param string $dst target dir + * + * @return bool true on success or false on failure. + */ +function rcopy($src, $dst) +{ + if (is_dir($src)) { + // Make the destination directory if not exist + mkdirAndCheck($dst); + // open the source directory + $dir = opendir($src); + + // Loop through the files in source directory + while ($file = readdir($dir)) { + if (($file != '.') && ($file != '..')) { + if (is_dir($src . '/' . $file)) { + // Recursively calling custom copy function + // for sub directory + if (!rcopy($src . '/' . $file, $dst . '/' . $file)) { + return false; + } + } else { + if (!is_excluded($file)) { + if (!copy($src . '/' . $file, $dst . '/' . $file)) { + return false; + } + } + } + } + } + closedir($dir); + } elseif (is_file($src)) { + if (!is_excluded($src)) { + if (!copy($src, $dst)) { + return false; + } + } + } + return true; +} + +/** + * build a zip file with only php code and no external depends + * on "zip" exec for example + * + * @param string $folder folder to use as zip root + * @param ZipArchive $zip zip object (ZipArchive) + * @param string $root relative root path into the zip + * + * @return bool true on success or false on failure. + */ +function zipDir($folder, &$zip, $root = "") +{ + foreach (new \DirectoryIterator($folder) as $f) { + if ($f->isDot()) { + continue; + } //skip . .. + $src = $folder . '/' . $f; + $dst = substr($f->getPathname(), strlen($root)); + if ($f->isDir()) { + if ($zip->addEmptyDir($dst)) { + if (zipDir($src, $zip, $root)) { + continue; + } else { + return false; + } + } else { + return false; + } + } + if ($f->isFile()) { + if (! $zip->addFile($src, $dst)) { + return false; + } + } + } + return true; +} + +/** + * main part of script + */ + +list($mod, $version) = detectModule(); +$outzip = sys_get_temp_dir() . "/module_" . $mod . "-" . $version . ".zip"; +if (file_exists($outzip)) { + secureUnlink($outzip); +} + +//copy all sources into system temp directory +$tmpdir = tempnam(sys_get_temp_dir(), $mod . "-module"); +secureUnlink($tmpdir); +mkdirAndCheck($tmpdir); +$dst = $tmpdir . "/" . $mod; +mkdirAndCheck($dst); + +foreach ($listOfModuleContent as $moduleContent) { + foreach (glob($moduleContent) as $entry) { + if (!rcopy($entry, $dst . '/' . $entry)) { + echo "[fail] Error on copy " . $entry . " to " . $dst . "/" . $entry . "\n"; + echo "Please take time to analyze the problem and fix the bug\n"; + exit(-8); + } + } +} + +$z = new ZipArchive(); +$z->open($outzip, ZIPARCHIVE::CREATE); +zipDir($tmpdir, $z, $tmpdir . '/'); +$z->close(); +delTree($tmpdir); +if (file_exists($outzip)) { + echo "[success] module archive is ready : $outzip ...\n"; +} else { + echo "[fail] build zip error\n"; + exit(-9); +} diff --git a/build/makepack-subtotaltitle.conf b/build/makepack-subtotaltitle.conf new file mode 100755 index 0000000..16dc1e7 --- /dev/null +++ b/build/makepack-subtotaltitle.conf @@ -0,0 +1,11 @@ +# Your module name here +# +# Goal: Goal of module +# Version: +# Author: Copyright - +# License: GPLv3 +# Install: Just unpack content of module package in Dolibarr directory. +# Setup: Go on Dolibarr setup - modules to enable module. +# +# Files in module +mymodule/ \ No newline at end of file diff --git a/class/DocumentTypeHelper.class.php b/class/DocumentTypeHelper.class.php new file mode 100644 index 0000000..af4c2ab --- /dev/null +++ b/class/DocumentTypeHelper.class.php @@ -0,0 +1,125 @@ + + * + * 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 . + */ + +/** + * \file subtotaltitle/class/DocumentTypeHelper.class.php + * \ingroup subtotaltitle + * \brief Helper class für verschiedene Dokumenttypen (Rechnung, Angebot, Auftrag) + */ + +/** + * Class DocumentTypeHelper + * Hilfsklasse um mit verschiedenen Dokumenttypen zu arbeiten + */ +class DocumentTypeHelper +{ + /** + * Erkennt den Dokumenttyp aus dem Context + * + * @param string $context Hook-Context (z.B. 'invoicecard', 'propalcard', 'ordercard') + * @return string|null Dokumenttyp ('invoice', 'propal', 'order') oder null + */ + public static function getTypeFromContext($context) + { + if (strpos($context, 'invoicecard') !== false) { + return 'invoice'; + } + if (strpos($context, 'propalcard') !== false) { + return 'propal'; + } + if (strpos($context, 'ordercard') !== false) { + return 'order'; + } + return null; + } + + /** + * Holt den Dokumenttyp aus dem Object + * + * @param object $object Dolibarr Objekt + * @return string|null Dokumenttyp ('invoice', 'propal', 'order') oder null + */ + public static function getTypeFromObject($object) + { + if (!is_object($object) || !isset($object->element)) { + return null; + } + + if ($object->element == 'facture') { + return 'invoice'; + } + if ($object->element == 'propal') { + return 'propal'; + } + if ($object->element == 'commande') { + return 'order'; + } + return null; + } + + /** + * Holt die Tabellennamen für einen Dokumenttyp + * + * @param string $type Dokumenttyp ('invoice', 'propal', 'order') + * @return array Array mit Tabellennamen (parent_table, lines_table, fk_parent, fk_line) + */ + public static function getTableNames($type) + { + $tables = array( + 'invoice' => array( + 'parent_table' => 'facture', + 'lines_table' => 'facturedet', + 'fk_parent' => 'fk_facture', + 'fk_line' => 'fk_facturedet', + 'element' => 'facture' + ), + 'propal' => array( + 'parent_table' => 'propal', + 'lines_table' => 'propaldet', + 'fk_parent' => 'fk_propal', + 'fk_line' => 'fk_propaldet', + 'element' => 'propal' + ), + 'order' => array( + 'parent_table' => 'commande', + 'lines_table' => 'commandedet', + 'fk_parent' => 'fk_commande', + 'fk_line' => 'fk_commandedet', + 'element' => 'commande' + ) + ); + + return isset($tables[$type]) ? $tables[$type] : null; + } + + /** + * Holt die Hook-Contexts für einen Dokumenttyp + * + * @param string $type Dokumenttyp ('invoice', 'propal', 'order') + * @return string Hook-Context + */ + public static function getContext($type) + { + $contexts = array( + 'invoice' => 'invoicecard', + 'propal' => 'propalcard', + 'order' => 'ordercard' + ); + + return isset($contexts[$type]) ? $contexts[$type] : ''; + } +} diff --git a/class/actions_subtotaltitle.class.php b/class/actions_subtotaltitle.class.php new file mode 100755 index 0000000..6f46d77 --- /dev/null +++ b/class/actions_subtotaltitle.class.php @@ -0,0 +1,1305 @@ + + * Copyright (C) 2026 Eduard Wisch + * + * 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 . + */ + +/** + * \file subtotaltitle/class/actions_subtotaltitle.class.php + * \ingroup subtotaltitle + * \brief Hook overload for SubtotalTitle module + */ + +require_once DOL_DOCUMENT_ROOT.'/core/class/commonhookactions.class.php'; +require_once __DIR__.'/DocumentTypeHelper.class.php'; + +/** + * Class ActionsSubtotalTitle + */ +class ActionsSubtotalTitle extends CommonHookActions +{ + + private $debug = 0; // Wird dynamisch aus Config geladen + + // Gemeinsames Array für gerenderte Sections + private static $rendered_sections = array(); + + // Aktueller Dokumentkontext (wird in formObjectOptions/printObjectLine gesetzt) + private $currentDocType = null; + private $currentDocumentId = null; + private $isDraft = false; + + /** + * @var DoliDB Database handler. + */ + public $db; + + /** + * Helper-Methode: Erstellt WHERE-Klausel für document_id + */ + private function getDocumentWhere($document_id, $docType, $tableAlias = 'm') + { + global $db; + $tables = DocumentTypeHelper::getTableNames($docType); + if (!$tables) return ""; + return " WHERE ".$tableAlias.".".$tables['fk_parent']." = ".(int)$document_id." AND ".$tableAlias.".document_type = '".$db->escape($docType)."'"; + } + + /** + * @var string Error code (or message) + */ + public $error = ''; + + /** + * @var string[] Errors + */ + public $errors = array(); + + /** + * @var mixed[] Hook results. Propagated to $hookmanager->resArray for later reuse + */ + public $results = array(); + + /** + * @var ?string String displayed by executeHook() immediately after return + */ + public $resprints; + + /** + * @var int Priority of hook (50 is used if value is not defined) + */ + public $priority; + + + /** + * Constructor + * + * @param DoliDB $db Database handler + */ + public function __construct($db) + { + global $conf; + $this->db = $db; + + // Debug-Modus aus Config laden + $this->debug = getDolGlobalInt('SUBTOTALTITLE_DEBUG_MODE', 0); + + // IMMER Debug-Log in Datei schreiben + $logFile = '/srv/http/dolibarr/documents/subtotaltitle/debug.log'; + @mkdir(dirname($logFile), 0777, true); + error_log('['.date('Y-m-d H:i:s').'] SubtotalTitle Constructor aufgerufen'."\n", 3, $logFile); + } + + + public function formObjectOptions($parameters, &$object, &$action, $hookmanager) + { + global $conf, $langs; + + // Prüfe ob es ein unterstützter Kontext ist + $supportedContexts = array('invoicecard', 'propalcard', 'ordercard'); + $currentContexts = explode(':', $parameters['currentcontext']); + $isSupported = false; + foreach ($supportedContexts as $ctx) { + if (in_array($ctx, $currentContexts)) { + $isSupported = true; + break; + } + } + + if ($isSupported) { + // Setze Klassenvariablen für den aktuellen Dokumentkontext + $this->currentDocType = DocumentTypeHelper::getTypeFromObject($object); + $this->currentDocumentId = $object->id; + + // Prüfe ob Dokument bearbeitbar ist (nur im Entwurfsstatus) + $this->isDraft = false; + if (isset($object->statut)) { + $this->isDraft = ($object->statut == 0); + // Debug-Log + $logFile = '/srv/http/dolibarr/documents/subtotaltitle/debug.log'; + error_log('['.date('Y-m-d H:i:s').'] formObjectOptions - DocType: '.$this->currentDocType.', statut: '.$object->statut.', isDraft: '.($this->isDraft ? 'true' : 'false')."\n", 3, $logFile); + } elseif (isset($object->status)) { + $this->isDraft = ($object->status == 0); + // Debug-Log + $logFile = '/srv/http/dolibarr/documents/subtotaltitle/debug.log'; + error_log('['.date('Y-m-d H:i:s').'] formObjectOptions - DocType: '.$this->currentDocType.', status: '.$object->status.', isDraft: '.($this->isDraft ? 'true' : 'false')."\n", 3, $logFile); + } + $is_draft = $this->isDraft; + + // Lade Übersetzungen + $langs->load('subtotaltitle@subtotaltitle'); + + // CSS + $cssPath = dol_buildpath('/custom/subtotaltitle/css/subtotaltitle.css', 1); + echo ''."\n"; + + // Übersetzungen als JavaScript-Variablen bereitstellen + echo ''."\n"; + + // Haupt-JavaScript + $jsPath = dol_buildpath('/custom/subtotaltitle/js/subtotaltitle.js', 1); + echo ''."\n"; + + // Sync-JavaScript (NEU!) + $jsSyncPath = dol_buildpath('/custom/subtotaltitle/js/subtotaltitle_sync.js', 1); + echo ''."\n"; + + // Buttons nur im Entwurfsstatus anzeigen + if ($is_draft) { + // Textzeile-Button + echo ''."\n"; + + // Massenlösch-Button (ans ENDE der Hauptzeile) - NUR EINMAL EINFÜGEN + echo ''."\n"; + + // Sync-Buttons + Collapse-Buttons - rechts ausgerichtet + echo ''."\n"; + } + } + + return 0; + } + + + /** + * Execute action + */ + public function getNomUrl($parameters, &$object, &$action) + { + global $db, $langs, $conf, $user; + $this->resprints = ''; + return 0; + } + + /** + * Overload the doActions function + */ + public function doActions($parameters, &$object, &$action, $hookmanager) + { + return 0; + } + + + /** + * Overload the doMassActions function + */ + public function doMassActions($parameters, &$object, &$action, $hookmanager) + { + global $conf, $user, $langs; + + $error = 0; + + if (in_array($parameters['currentcontext'], array('somecontext1', 'somecontext2'))) { + foreach ($parameters['toselect'] as $objectid) { + // Do action on each object id + } + + if (!$error) { + $this->results = array('myreturn' => 999); + $this->resprints = 'A text to show'; + return 0; + } else { + $this->errors[] = 'Error message'; + return -1; + } + } + + return 0; + } + + + /** + * Overload the addMoreMassActions function + */ + public function addMoreMassActions($parameters, &$object, &$action, $hookmanager) + { + global $conf, $user, $langs; + + $error = 0; + $disabled = 1; + + if (in_array($parameters['currentcontext'], array('somecontext1', 'somecontext2'))) { + $this->resprints = ''; + } + + if (!$error) { + return 0; + } else { + $this->errors[] = 'Error message'; + return -1; + } + } + + + /** + * Execute action before PDF (document) creation + */ + public function beforePDFCreation($parameters, &$object, &$action) + { + global $conf, $user, $langs; + global $hookmanager; + + $outputlangs = $langs; + + $ret = 0; + $deltemp = array(); + dol_syslog(get_class($this).'::executeHooks action='.$action); + + if (in_array($parameters['currentcontext'], array('somecontext1', 'somecontext2'))) { + // do something only for the context 'somecontext1' or 'somecontext2' + } + + return $ret; + } + + /** + * Execute action after PDF (document) creation + */ + public function afterPDFCreation($parameters, &$pdfhandler, &$action) + { + global $conf, $user, $langs; + global $hookmanager; + + $outputlangs = $langs; + + $ret = 0; + $deltemp = array(); + dol_syslog(get_class($this).'::executeHooks action='.$action); + + if (in_array($parameters['currentcontext'], array('somecontext1', 'somecontext2'))) { + // do something only for the context 'somecontext1' or 'somecontext2' + } + + return $ret; + } + + + /** + * Overload the loadDataForCustomReports function + */ + public function loadDataForCustomReports($parameters, &$action, $hookmanager) + { + global $langs; + + $langs->load("subtotaltitle@subtotaltitle"); + + $this->results = array(); + + $head = array(); + $h = 0; + + if ($parameters['tabfamily'] == 'subtotaltitle') { + $head[$h][0] = dol_buildpath('/module/index.php', 1); + $head[$h][1] = $langs->trans("Home"); + $head[$h][2] = 'home'; + $h++; + + $this->results['title'] = $langs->trans("SubtotalTitle"); + $this->results['picto'] = 'subtotaltitle@subtotaltitle'; + } + + $head[$h][0] = 'customreports.php?objecttype='.$parameters['objecttype'].(empty($parameters['tabfamily']) ? '' : '&tabfamily='.$parameters['tabfamily']); + $head[$h][1] = $langs->trans("CustomReports"); + $head[$h][2] = 'customreports'; + + $this->results['head'] = $head; + + $arrayoftypes = array(); + + $this->results['arrayoftype'] = $arrayoftypes; + + return 0; + } + + + /** + * Overload the restrictedArea function + */ + public function restrictedArea($parameters, $object, &$action, $hookmanager) + { + global $user; + + if ($parameters['features'] == 'myobject') { + if ($user->hasRight('subtotaltitle', 'myobject', 'read')) { + $this->results['result'] = 1; + return 1; + } else { + $this->results['result'] = 0; + return 1; + } + } + + return 0; + } + + /** + * Execute action completeTabsHead + */ + public function completeTabsHead(&$parameters, &$object, &$action, $hookmanager) + { + global $langs, $conf, $user; + + if (!isset($parameters['object']->element)) { + return 0; + } + if ($parameters['mode'] == 'remove') { + return 0; + } elseif ($parameters['mode'] == 'add') { + $langs->load('subtotaltitle@subtotaltitle'); + $counter = count($parameters['head']); + $element = $parameters['object']->element; + $id = $parameters['object']->id; + + if (in_array($element, ['context1', 'context2'])) { + $datacount = 0; + + $parameters['head'][$counter][0] = dol_buildpath('/subtotaltitle/subtotaltitle_tab.php', 1) . '?id=' . $id . '&module='.$element; + $parameters['head'][$counter][1] = $langs->trans('SubtotalTitleTab'); + if ($datacount > 0) { + $parameters['head'][$counter][1] .= '' . $datacount . ''; + } + $parameters['head'][$counter][2] = 'subtotaltitleemails'; + $counter++; + } + if ($counter > 0 && (int) DOL_VERSION < 14) { + $this->results = $parameters['head']; + return 1; + } else { + return 0; + } + } else { + return -1; + } + } + + + /** + * Overload the showLinkToObjectBlock function + */ + public function showLinkToObjectBlock($parameters, &$object, &$action, $hookmanager) + { + return 0; + } + + /** + * Hook: Wird aufgerufen wenn eine Rechnung geladen wird + * Synchronisiert die Tabelle und rendert Section-Header + */ + public function printObjectLine($parameters, &$object, &$action, $hookmanager) + { + global $db, $langs; + + // Erkenne Dokumenttyp + $docType = DocumentTypeHelper::getTypeFromObject($object); + if (!$docType) { + return 0; + } + + // Prüfe Context + $expectedContext = DocumentTypeHelper::getContext($docType); + if ($parameters['currentcontext'] != $expectedContext) { + return 0; + } + + $document_id = $object->id; + + // Setze Klassenvariablen für den aktuellen Dokumentkontext + $this->currentDocType = $docType; + $this->currentDocumentId = $document_id; + + // Prüfe ob Dokument bearbeitbar ist (nur im Entwurfsstatus) + $this->isDraft = false; + if (isset($object->statut)) { + $this->isDraft = ($object->statut == 0); + // Debug-Log + $logFile = '/srv/http/dolibarr/documents/subtotaltitle/debug.log'; + error_log('['.date('Y-m-d H:i:s').'] printObjectLine - DocType: '.$this->currentDocType.', statut: '.$object->statut.', isDraft: '.($this->isDraft ? 'true' : 'false')."\n", 3, $logFile); + } elseif (isset($object->status)) { + $this->isDraft = ($object->status == 0); + // Debug-Log + $logFile = '/srv/http/dolibarr/documents/subtotaltitle/debug.log'; + error_log('['.date('Y-m-d H:i:s').'] printObjectLine - DocType: '.$this->currentDocType.', status: '.$object->status.', isDraft: '.($this->isDraft ? 'true' : 'false')."\n", 3, $logFile); + } + + $line = $parameters['line']; + + if (!$line || !$line->id) { + return 0; + } + + // Prüfe ob diese Zeile eine unserer speziellen Zeilen ist (Section, Text, Subtotal) + // special_code: 100=Section, 101=Text, 102=Subtotal + if (isset($line->special_code) && in_array($line->special_code, array(100, 101, 102))) { + // Diese Zeile ist eine unserer speziellen Zeilen - per JS ausblenden + echo ''; + return 0; + } + + // Dokument neu laden um aktuelle rang zu haben + static $reloaded = array(); + $reload_key = $docType.'_'.$document_id; + if (!isset($reloaded[$reload_key])) { + $object->fetch($document_id); + $object->fetch_thirdparty(); + $object->fetch_lines(); + $reloaded[$reload_key] = true; + } + + // Synchronisiere Manager-Tabelle + $this->syncManagerTable($document_id, $docType); + + // Rendere alle Sections die VOR dieser Zeile kommen sollten (inkl. leere!) + $this->renderAllPendingSections($document_id, $line, $docType); + + // WICHTIG: Markiere diese Zeile mit line_order UND parent_section für JavaScript + $tables = DocumentTypeHelper::getTableNames($docType); + $sql = "SELECT line_order, parent_section FROM ".MAIN_DB_PREFIX."facture_lines_manager"; + $sql .= " WHERE ".$tables['fk_line']." = ".(int)$line->id; + $resql = $db->query($sql); + if ($obj = $db->fetch_object($resql)) { + $parentSection = $obj->parent_section ? $obj->parent_section : 'null'; + echo ''; + } + + return 0; + } + + /** + * Rendert ALLE Sections die VOR dieser RANG-Position kommen sollten + */ + private function renderAllPendingSections($document_id, $line, $docType) + { + global $db; + + $tables = DocumentTypeHelper::getTableNames($docType); + if (!$tables) return; + + static $last_rang = array(); + static $last_parent_section = array(); + + $doc_key = $docType.'_'.$document_id; + if (!isset(self::$rendered_sections[$doc_key])) { + self::$rendered_sections[$doc_key] = array(); + $last_rang[$doc_key] = 0; + $last_parent_section[$doc_key] = null; + } + + // Hole rang dieser Produktzeile + $sql = "SELECT rang FROM ".MAIN_DB_PREFIX.$tables['lines_table']; + $sql .= " WHERE rowid = ".(int)$line->id; + $resql = $db->query($sql); + + if (!$resql || $db->num_rows($resql) == 0) return; + + $current_rang = $db->fetch_object($resql)->rang; + + // Hole line_order und parent_section des aktuellen Produkts aus Manager-Tabelle + $sql_current = "SELECT line_order, parent_section FROM ".MAIN_DB_PREFIX."facture_lines_manager"; + $sql_current .= " WHERE ".$tables['fk_line']." = ".(int)$line->id." AND document_type = '".$db->escape($docType)."'"; + $res_current = $db->query($sql_current); + $current_line_order = 0; + $current_parent_section = null; + if ($obj_current = $db->fetch_object($res_current)) { + $current_line_order = $obj_current->line_order; + $current_parent_section = $obj_current->parent_section; + } + + // Subtotal der VORHERIGEN Section rendern (wenn Section-Wechsel) + if ($last_parent_section[$doc_key] && $last_parent_section[$doc_key] != $current_parent_section) { + $sql_subtotal = "SELECT rowid, title, parent_section, line_order FROM ".MAIN_DB_PREFIX."facture_lines_manager"; + $sql_subtotal .= " WHERE ".$tables['fk_parent']." = ".(int)$document_id; + $sql_subtotal .= " AND document_type = '".$db->escape($docType)."'"; + $sql_subtotal .= " AND parent_section = ".(int)$last_parent_section[$doc_key]; + $sql_subtotal .= " AND line_type = 'subtotal'"; + $resql_subtotal = $db->query($sql_subtotal); + + if ($obj_sub = $db->fetch_object($resql_subtotal)) { + $subtotal_key = 'subtotal_'.$obj_sub->rowid; + if (!in_array($subtotal_key, self::$rendered_sections[$doc_key])) { + echo $this->renderSubtotalLine($obj_sub); + self::$rendered_sections[$doc_key][] = $subtotal_key; + + if ($this->debug) { + error_log('[SubtotalTitle] ✅ Subtotal "'.$obj_sub->title.'" gerendert (Section-Wechsel)'); + } + } + } + } + + // Hole ALLE Sections ZWISCHEN letztem rang und aktuellem rang + $sql = "SELECT DISTINCT s.rowid, s.title, s.show_subtotal, s.collapsed, s.line_order, s.in_facturedet,"; + $sql .= " (SELECT MIN(fd.rang) FROM ".MAIN_DB_PREFIX."facture_lines_manager m2"; + $sql .= " INNER JOIN ".MAIN_DB_PREFIX.$tables['lines_table']." fd ON fd.rowid = m2.".$tables['fk_line']; + $sql .= " WHERE m2.parent_section = s.rowid AND m2.document_type = '".$db->escape($docType)."') as first_product_rang"; + $sql .= " FROM ".MAIN_DB_PREFIX."facture_lines_manager s"; + $sql .= $this->getDocumentWhere($document_id, $docType, 's'); + $sql .= " AND s.line_type = 'section'"; + $sql .= " HAVING first_product_rang > ".(int)$last_rang[$doc_key]; + $sql .= " AND first_product_rang <= ".(int)$current_rang; + $sql .= " ORDER BY first_product_rang"; + $resql = $db->query($sql); + + while ($obj = $db->fetch_object($resql)) { + if (!in_array($obj->rowid, self::$rendered_sections[$doc_key])) { + $section = array( + 'section_id' => $obj->rowid, + 'title' => $obj->title, + 'show_subtotal' => $obj->show_subtotal, + 'collapsed' => $obj->collapsed, + 'in_facturedet' => $obj->in_facturedet + ); + echo $this->renderSectionHeader($section); + self::$rendered_sections[$doc_key][] = $obj->rowid; + + if ($this->debug) { + error_log('[SubtotalTitle] ✅ Section "'.$obj->title.'" gerendert vor rang '.$current_rang); + } + } + } + + // Rendere Textzeilen die VOR dieser Produktzeile kommen + $sql_text = "SELECT rowid, title, line_order, in_facturedet FROM ".MAIN_DB_PREFIX."facture_lines_manager"; + $sql_text .= $this->getDocumentWhere($document_id, $docType); + $sql_text .= " AND line_type = 'text'"; + $sql_text .= " AND line_order < ".(int)$current_line_order; + $sql_text .= " ORDER BY line_order"; + $resql_text = $db->query($sql_text); + + if ($resql_text) { + while ($obj_text = $db->fetch_object($resql_text)) { + $text_key = 'text_'.$obj_text->rowid; + if (!in_array($text_key, self::$rendered_sections[$doc_key])) { + $textline = array( + 'id' => $obj_text->rowid, + 'title' => $obj_text->title, + 'line_order' => $obj_text->line_order, + 'in_facturedet' => $obj_text->in_facturedet + ); + echo $this->renderTextLine($textline); + self::$rendered_sections[$doc_key][] = $text_key; + + if ($this->debug) { + error_log('[SubtotalTitle] ✅ Textzeile "'.$obj_text->title.'" gerendert'); + } + } + } + } + + // Merke für nächsten Durchlauf + $last_rang[$doc_key] = $current_rang; + $last_parent_section[$doc_key] = $current_parent_section; + } + + /** + * Rendert eine Textzeile + */ + private function renderTextLine($textline) + { + global $db; + + // In-Facturedet Status + $in_facturedet = isset($textline['in_facturedet']) ? $textline['in_facturedet'] : 0; + $sync_checked = $in_facturedet ? 'checked' : ''; + $in_class = $in_facturedet ? ' in-facturedet' : ''; + + $html = ''; + + // Inhalt (colspan=10) + $html .= ''; + $html .= htmlspecialchars($textline['title']); + + // Sync-Checkbox (NEU!) - nur im Entwurfsstatus + if ($this->isDraft) { + $html .= ' '; + } + + $html .= ''; + + // Edit (Spalte 11) - nur im Entwurfsstatus + $html .= ''; + if ($this->isDraft) { + $html .= ''; + $html .= ''; + } + $html .= ''; + + // Delete (Spalte 12) - nur im Entwurfsstatus + $html .= ''; + if ($this->isDraft) { + $html .= ''; + $html .= ''; + } + $html .= ''; + + // Move (Spalte 13) + $html .= ''; + + // Unlink (Spalte 14) + $html .= ''; + + $html .= ''; + + return $html; + } + + /** + * Rendert eine Subtotal-Zeile + */ + private function renderSubtotalLine($subtotal) + { + global $db; + + $tables = DocumentTypeHelper::getTableNames($this->currentDocType); + if (!$tables) return ''; + + // Hole in_facturedet Status + $sql_sync = "SELECT in_facturedet FROM ".MAIN_DB_PREFIX."facture_lines_manager WHERE rowid = ".(int)$subtotal->rowid; + $res_sync = $db->query($sql_sync); + $obj_sync = $db->fetch_object($res_sync); + $in_facturedet = $obj_sync ? $obj_sync->in_facturedet : 0; + $sync_checked = $in_facturedet ? 'checked' : ''; + $in_class = $in_facturedet ? ' in-facturedet' : ''; + + // Berechne aktuelle Summe + $sql = "SELECT SUM(d.total_ht) as total"; + $sql .= " FROM ".MAIN_DB_PREFIX."facture_lines_manager m"; + $sql .= " INNER JOIN ".MAIN_DB_PREFIX.$tables['lines_table']." d ON d.rowid = m.".$tables['fk_line']; + $sql .= " WHERE m.parent_section = ".(int)$subtotal->parent_section; + $sql .= " AND m.".$tables['fk_parent']." = ".(int)$this->currentDocumentId; + $sql .= " AND m.document_type = '".$db->escape($this->currentDocType)."'"; + $sql .= " AND m.line_type = 'product'"; + $resql = $db->query($sql); + $sum = 0; + if ($resql && ($obj = $db->fetch_object($resql))) { + $sum = $obj->total ? $obj->total : 0; + } + + $formatted = number_format($sum, 2, ',', '.'); + + $html = ''; + $html .= ''; + $html .= 'Zwischensumme:'; + + // Sync-Checkbox (NEU!) - nur im Entwurfsstatus + if ($this->isDraft) { + $html .= ' '; + } + + $html .= ''; + $html .= ''.$formatted.''; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + + return $html; + } + + /** + * Hook: Wird aufgerufen wenn das Produkt-Hinzufügen-Formular gerendert wird + * Fügt Section-Dropdown hinzu + */ + public function formAddObjectLine($parameters, &$object, &$action, $hookmanager) + { + global $db; + + // Erkenne Dokumenttyp + $docType = DocumentTypeHelper::getTypeFromObject($object); + if (!$docType) { + return 0; + } + + // Prüfe Context + $expectedContext = DocumentTypeHelper::getContext($docType); + if ($parameters['currentcontext'] != $expectedContext) { + return 0; + } + + $document_id = $object->id; + + echo $this->renderSectionDropdown($document_id, $docType); + + return 0; + } + + /** + * Synchronisiert llx_facture_lines_manager mit vorhandenen Zeilen + */ + private function syncManagerTable($document_id, $docType) + { + global $db; + + $tables = DocumentTypeHelper::getTableNames($docType); + if (!$tables) return; + + // 1. CLEANUP: Lösche verwaiste Einträge + $sql_cleanup = "DELETE m FROM ".MAIN_DB_PREFIX."facture_lines_manager m"; + $sql_cleanup .= " LEFT JOIN ".MAIN_DB_PREFIX.$tables['lines_table']." d ON m.".$tables['fk_line']." = d.rowid"; + $sql_cleanup .= " WHERE m.".$tables['fk_parent']." = ".(int)$document_id; + $sql_cleanup .= " AND m.line_type = 'product'"; + $sql_cleanup .= " AND d.rowid IS NULL"; + $result = $db->query($sql_cleanup); + + // 2. Hole alle Produktzeilen des Dokuments + $sql = "SELECT rowid FROM ".MAIN_DB_PREFIX.$tables['lines_table']; + $sql .= " WHERE ".$tables['fk_parent']." = ".(int)$document_id; + $sql .= " ORDER BY rang"; + $resql = $db->query($sql); + + while ($obj = $db->fetch_object($resql)) { + $sql_check = "SELECT rowid FROM ".MAIN_DB_PREFIX."facture_lines_manager"; + $sql_check .= " WHERE ".$tables['fk_line']." = ".(int)$obj->rowid; + $resql_check = $db->query($sql_check); + + if ($db->num_rows($resql_check) == 0) { + $next_order = $this->getNextLineOrder($document_id, $docType); + + // Setze alle FK-Felder explizit (NULL für nicht genutzte) + $fk_facture = ($docType === 'invoice') ? (int)$document_id : 'NULL'; + $fk_propal = ($docType === 'propal') ? (int)$document_id : 'NULL'; + $fk_commande = ($docType === 'order') ? (int)$document_id : 'NULL'; + + $sql_ins = "INSERT INTO ".MAIN_DB_PREFIX."facture_lines_manager"; + $sql_ins .= " (fk_facture, fk_propal, fk_commande, document_type, line_type, ".$tables['fk_line'].", line_order, date_creation)"; + $sql_ins .= " VALUES (".$fk_facture.", ".$fk_propal.", ".$fk_commande.", '".$db->escape($docType)."', 'product', ".(int)$obj->rowid.", ".$next_order.", NOW())"; + $db->query($sql_ins); + } + } + } + + /** + * Holt die Section für eine Produktzeile + */ + private function getSectionForLine($line_id) + { + global $db; + + $tables = DocumentTypeHelper::getTableNames($this->currentDocType); + if (!$tables) return null; + + $sql = "SELECT m.parent_section as section_id, s.title, s.show_subtotal, s.collapsed"; + $sql .= " FROM ".MAIN_DB_PREFIX."facture_lines_manager m"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."facture_lines_manager s ON s.rowid = m.parent_section"; + $sql .= " WHERE m.".$tables['fk_line']." = ".(int)$line_id; + $sql .= " AND m.document_type = '".$db->escape($this->currentDocType)."'"; + $sql .= " AND m.line_type = 'product'"; + + $resql = $db->query($sql); + + if ($obj = $db->fetch_object($resql)) { + if ($obj->section_id) { + return array( + 'section_id' => $obj->section_id, + 'title' => $obj->title, + 'show_subtotal' => $obj->show_subtotal, + 'collapsed' => $obj->collapsed + ); + } + } + + return null; + } + + /** + * Prüft ob dies die erste Zeile einer Section ist + */ + private function isFirstLineInSection($line_id, $section_id) + { + global $db; + + $tables = DocumentTypeHelper::getTableNames($this->currentDocType); + if (!$tables) return false; + + $sql = "SELECT line_order FROM ".MAIN_DB_PREFIX."facture_lines_manager"; + $sql .= " WHERE ".$tables['fk_line']." = ".(int)$line_id; + $sql .= " AND document_type = '".$db->escape($this->currentDocType)."'"; + $resql = $db->query($sql); + if (!$resql || !($obj = $db->fetch_object($resql))) { + return false; + } + $current_order = $obj->line_order; + + $sql = "SELECT MIN(line_order) as min_order"; + $sql .= " FROM ".MAIN_DB_PREFIX."facture_lines_manager"; + $sql .= " WHERE parent_section = ".(int)$section_id; + $sql .= " AND line_type = 'product'"; + $resql = $db->query($sql); + if (!$resql || !($obj = $db->fetch_object($resql))) { + return false; + } + + return ($current_order == $obj->min_order); + } + + /** + * Rendert einen Section-Header + */ + private function renderSectionHeader($section) + { + global $db; + + $tables = DocumentTypeHelper::getTableNames($this->currentDocType); + if (!$tables) return ''; + + // Hole line_order und in_facturedet der Section + $sql = "SELECT line_order, in_facturedet FROM ".MAIN_DB_PREFIX."facture_lines_manager"; + $sql .= " WHERE rowid = ".(int)$section['section_id']; + $resql = $db->query($sql); + $line_order = 0; + $in_facturedet = 0; + if ($obj = $db->fetch_object($resql)) { + $line_order = $obj->line_order; + $in_facturedet = $obj->in_facturedet; + } + + $sync_checked = $in_facturedet ? 'checked' : ''; + $in_class = $in_facturedet ? ' in-facturedet' : ''; + + // Hole Produkte dieser Section (IDs + Anzahl) + $sql_products = "SELECT ".$tables['fk_line']." FROM ".MAIN_DB_PREFIX."facture_lines_manager"; + $sql_products .= " WHERE parent_section = ".(int)$section['section_id']; + $sql_products .= " AND ".$tables['fk_parent']." = ".(int)$this->currentDocumentId; + $sql_products .= " AND document_type = '".$db->escape($this->currentDocType)."'"; + $sql_products .= " AND line_type = 'product'"; + $res_products = $db->query($sql_products); + + $product_ids = []; + while ($obj_prod = $db->fetch_object($res_products)) { + $fk_line_col = $tables['fk_line']; + $product_ids[] = (int)$obj_prod->$fk_line_col; + } + $product_count = count($product_ids); + $product_ids_json = htmlspecialchars(json_encode($product_ids)); + + $html = ''; + + // Titel (colspan=10) + $html .= ''; + $html .= ''; + $html .= htmlspecialchars($section['title']); + $html .= ''.$product_count.' Produkte'; + + + // Zwischensummen-Checkbox - nur im Entwurfsstatus + if ($this->isDraft) { + $checked = $section['show_subtotal'] ? 'checked' : ''; + $html .= ' '; + + // Sync-Checkbox (NEU!) + $html .= ' '; + } + $html .= ''; + + // Move-Buttons - nur im Entwurfsstatus + if ($this->isDraft) { + $html .= ' '; + $html .= ' '; + } + $html .= ''; + + // Edit (Spalte 11) - nur im Entwurfsstatus + $html .= ''; + if ($this->isDraft) { + $html .= ''; + $html .= ''; + } + $html .= ''; + + // Delete (Spalte 12) - nur im Entwurfsstatus + $html .= ''; + if ($this->isDraft) { + if ($product_count == 0) { + $html .= ''; + $html .= ''; + } else { + $html .= ''; + $html .= ''; + } + } + $html .= ''; + + // Move (Spalte 13) + $html .= ''; + + // Unlink (Spalte 14) + $html .= ''; + + $html .= ''; + + return $html; + } + + /** + * Rendert Section-Dropdown im Add-Product-Formular + */ + private function renderSectionDropdown($document_id, $docType) + { + global $db; + + $tables = DocumentTypeHelper::getTableNames($docType); + if (!$tables) return ''; + + $sql = "SELECT rowid, title FROM ".MAIN_DB_PREFIX."facture_lines_manager"; + $sql .= " WHERE ".$tables['fk_parent']." = ".(int)$document_id; + $sql .= " AND document_type = '".$db->escape($docType)."'"; + $sql .= " AND line_type = 'section'"; + $sql .= " ORDER BY line_order"; + $resql = $db->query($sql); + + $html = ''; + + return $html; + } + + /** + * Fügt ein Produkt zu einer Section hinzu + */ + private function addProductToSection($doc_key, $line_id, $section_id) + { + global $db; + + $tables = DocumentTypeHelper::getTableNames($this->currentDocType); + if (!$tables) return; + + if ($this->debug) { + error_log('[SubtotalTitle] 🔴 addProductToSection: doc='.$doc_key.', line='.$line_id.', section='.$section_id); + } + + $sql_check = "SELECT rowid, line_order FROM ".MAIN_DB_PREFIX."facture_lines_manager"; + $sql_check .= " WHERE ".$tables['fk_line']." = ".(int)$line_id; + $sql_check .= " AND document_type = '".$db->escape($this->currentDocType)."'"; + $resql_check = $db->query($sql_check); + + if ($db->num_rows($resql_check) > 0) { + $obj = $db->fetch_object($resql_check); + + $sql_upd = "UPDATE ".MAIN_DB_PREFIX."facture_lines_manager"; + $sql_upd .= " SET parent_section = ".(int)$section_id; + $sql_upd .= " WHERE rowid = ".(int)$obj->rowid; + $db->query($sql_upd); + + $this->reorderLines($doc_key); + + } else { + $sql = "SELECT line_order FROM ".MAIN_DB_PREFIX."facture_lines_manager"; + $sql .= " WHERE rowid = ".(int)$section_id; + $resql = $db->query($sql); + if (!$resql || !($obj = $db->fetch_object($resql))) { + return; + } + $section_order = $obj->line_order; + + $new_order = $section_order + 1; + + $sql = "UPDATE ".MAIN_DB_PREFIX."facture_lines_manager"; + $sql .= " SET line_order = line_order + 1"; + $sql .= " WHERE ".$tables['fk_parent']." = ".(int)$doc_key; + $sql .= " AND document_type = '".$db->escape($this->currentDocType)."'"; + $sql .= " AND line_order >= ".$new_order; + $db->query($sql); + + $sql = "INSERT INTO ".MAIN_DB_PREFIX."facture_lines_manager"; + $sql .= " (".$tables['fk_parent'].", document_type, line_type, ".$tables['fk_line'].", parent_section, line_order, date_creation)"; + $sql .= " VALUES (".(int)$doc_key.", '".$db->escape($this->currentDocType)."', 'product', ".(int)$line_id.", ".(int)$section_id.", ".$new_order.", NOW())"; + $db->query($sql); + } + } + + /** + * Fügt ein freies Produkt hinzu + */ + private function addFreeProduct($doc_key, $line_id) + { + global $db; + + $tables = DocumentTypeHelper::getTableNames($this->currentDocType); + if (!$tables) return; + + if ($this->debug) { + error_log('[SubtotalTitle] 🟢 addFreeProduct: doc='.$doc_key.', line='.$line_id.' (parent_section=NULL)'); + } + + $next_order = $this->getNextLineOrder($doc_key, $this->currentDocType); + + $sql = "INSERT INTO ".MAIN_DB_PREFIX."facture_lines_manager"; + $sql .= " (".$tables['fk_parent'].", document_type, line_type, ".$tables['fk_line'].", line_order, date_creation)"; + $sql .= " VALUES (".(int)$doc_key.", '".$db->escape($this->currentDocType)."', 'product', ".(int)$line_id.", ".$next_order.", NOW())"; + $db->query($sql); + } + + /** + * Synchronisiert lines_table.rang aus line_order + */ + private function syncRangFromManager($doc_key) + { + global $db; + + $tables = DocumentTypeHelper::getTableNames($this->currentDocType); + if (!$tables) return; + + $sql = "SELECT ".$tables['fk_line'].", line_order"; + $sql .= " FROM ".MAIN_DB_PREFIX."facture_lines_manager"; + $sql .= " WHERE ".$tables['fk_parent']." = ".(int)$doc_key; + $sql .= " AND document_type = '".$db->escape($this->currentDocType)."'"; + $sql .= " AND line_type = 'product'"; + $sql .= " ORDER BY line_order"; + $resql = $db->query($sql); + + $rang = 1; + $fk_line_col = $tables['fk_line']; + while ($obj = $db->fetch_object($resql)) { + $sql_upd = "UPDATE ".MAIN_DB_PREFIX.$tables['lines_table']; + $sql_upd .= " SET rang = ".$rang; + $sql_upd .= " WHERE rowid = ".(int)$obj->$fk_line_col; + $db->query($sql_upd); + $rang++; + } + } + + /** + * Holt die nächste freie line_order + */ + private function getNextLineOrder($document_id, $docType) + { + global $db; + + $tables = DocumentTypeHelper::getTableNames($docType); + $sql = "SELECT MAX(line_order) as max_order"; + $sql .= " FROM ".MAIN_DB_PREFIX."facture_lines_manager"; + $sql .= $this->getDocumentWhere($document_id, $docType); + $resql = $db->query($sql); + if (!$resql || !($obj = $db->fetch_object($resql))) { + return 1; + } + + return ($obj->max_order ? $obj->max_order + 1 : 1); + } + + /** + * Holt die rowid der letzten Produktzeile + */ + private function getLastProductLine($doc_key) + { + global $db; + + $tables = DocumentTypeHelper::getTableNames($this->currentDocType); + if (!$tables) return 0; + + $fk_line_col = $tables['fk_line']; + $sql = "SELECT m.".$fk_line_col; + $sql .= " FROM ".MAIN_DB_PREFIX."facture_lines_manager m"; + $sql .= " WHERE m.".$tables['fk_parent']." = ".(int)$doc_key; + $sql .= " AND m.document_type = '".$db->escape($this->currentDocType)."'"; + $sql .= " AND m.line_type = 'product'"; + $sql .= " ORDER BY m.line_order DESC"; + $sql .= " LIMIT 1"; + $resql = $db->query($sql); + + if ($obj = $db->fetch_object($resql)) { + return $obj->$fk_line_col; + } + + return 0; + } + + /** + * Holt alle Sections die keine Produkte haben + */ + private function getEmptySections($doc_key) + { + global $db; + + $tables = DocumentTypeHelper::getTableNames($this->currentDocType); + if (!$tables) return array(); + + $sql = "SELECT s.rowid as section_id, s.title, s.show_subtotal, s.collapsed"; + $sql .= " FROM ".MAIN_DB_PREFIX."facture_lines_manager s"; + $sql .= " WHERE s.".$tables['fk_parent']." = ".(int)$doc_key; + $sql .= " AND s.document_type = '".$db->escape($this->currentDocType)."'"; + $sql .= " AND s.line_type = 'section'"; + $sql .= " AND NOT EXISTS ("; + $sql .= " SELECT 1 FROM ".MAIN_DB_PREFIX."facture_lines_manager p"; + $sql .= " WHERE p.parent_section = s.rowid"; + $sql .= " AND p.".$tables['fk_parent']." = ".(int)$doc_key; + $sql .= " AND p.document_type = '".$db->escape($this->currentDocType)."'"; + $sql .= " )"; + $sql .= " ORDER BY s.line_order"; + $resql = $db->query($sql); + + $sections = array(); + while ($obj = $db->fetch_object($resql)) { + $sections[] = array( + 'section_id' => $obj->section_id, + 'title' => $obj->title, + 'show_subtotal' => $obj->show_subtotal, + 'collapsed' => $obj->collapsed + ); + } + + return $sections; + } + + /** + * Sortiert alle Zeilen neu: Schließt nur Lücken, behält Reihenfolge bei + */ + private function reorderLines($doc_key) + { + global $db; + + $tables = DocumentTypeHelper::getTableNames($this->currentDocType); + if (!$tables) return; + + $sql = "SELECT rowid FROM ".MAIN_DB_PREFIX."facture_lines_manager"; + $sql .= " WHERE ".$tables['fk_parent']." = ".(int)$doc_key; + $sql .= " AND document_type = '".$db->escape($this->currentDocType)."'"; + $sql .= " ORDER BY line_order"; + $resql = $db->query($sql); + + $new_order = 1; + while ($obj = $db->fetch_object($resql)) { + $sql_upd = "UPDATE ".MAIN_DB_PREFIX."facture_lines_manager"; + $sql_upd .= " SET line_order = ".$new_order; + $sql_upd .= " WHERE rowid = ".(int)$obj->rowid; + $db->query($sql_upd); + $new_order++; + } + } +} diff --git a/core/modules/modSubtotalTitle.class.php b/core/modules/modSubtotalTitle.class.php new file mode 100755 index 0000000..f117cf4 --- /dev/null +++ b/core/modules/modSubtotalTitle.class.php @@ -0,0 +1,543 @@ + + * Copyright (C) 2018-2019 Nicolas ZABOURI + * Copyright (C) 2019-2024 Frédéric France + * Copyright (C) 2026 Eduard Wisch + * + * 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 . + */ + +/** + * \defgroup subtotaltitle Module SubtotalTitle + * \brief SubtotalTitle module descriptor. + * + * \file htdocs/subtotaltitle/core/modules/modSubtotalTitle.class.php + * \ingroup subtotaltitle + * \brief Description and activation file for module SubtotalTitle + */ +include_once DOL_DOCUMENT_ROOT.'/core/modules/DolibarrModules.class.php'; + + +/** + * Description and activation class for module SubtotalTitle + */ +class modSubtotalTitle extends DolibarrModules +{ + /** + * Constructor. Define names, constants, directories, boxes, permissions + * + * @param DoliDB $db Database handler + */ + public function __construct($db) + { + global $conf, $langs; + + $this->db = $db; + + // Id for module (must be unique). + // Use here a free id (See in Home -> System information -> Dolibarr for list of used modules id). + $this->numero = 500008; // TODO Go on page https://wiki.dolibarr.org/index.php/List_of_modules_id to reserve an id number for your module + + // Key text used to identify module (for permissions, menus, etc...) + $this->rights_class = 'subtotaltitle'; + + // Family can be 'base' (core modules),'crm','financial','hr','projects','products','ecm','technic' (transverse modules),'interface' (link with external tools),'other','...' + // It is used to group modules by family in module setup page + $this->family = "other"; + + // Module position in the family on 2 digits ('01', '10', '20', ...) + $this->module_position = '90'; + + // Gives the possibility for the module, to provide his own family info and position of this family (Overwrite $this->family and $this->module_position. Avoid this) + //$this->familyinfo = array('myownfamily' => array('position' => '01', 'label' => $langs->trans("MyOwnFamily"))); + // Module label (no space allowed), used if translation string 'ModuleSubtotalTitleName' not found (SubtotalTitle is name of module). + $this->name = preg_replace('/^mod/i', '', get_class($this)); + + // DESCRIPTION_FLAG + // Module description, used if translation string 'ModuleSubtotalTitleDesc' not found (SubtotalTitle is name of module). + $this->description = "Positionsgruppen und Zwischensummen für Rechnungen, Angebote und Kundenaufträge"; + // Used only if file README.md and README-LL.md not found. + $this->descriptionlong = "Organisieren Sie Positionen in Sections mit automatischen Zwischensummen. Fügen Sie Textzeilen hinzu und verwalten Sie komplexe Dokumente übersichtlich. Unterstützt Rechnungen, Angebote und Kundenaufträge."; + + // Author + $this->editor_name = 'Alles Watt läuft (Testsystem)'; + $this->editor_url = ''; // Must be an external online web site + $this->editor_squarred_logo = ''; // Must be image filename into the module/img directory followed with @modulename. Example: 'myimage.png@subtotaltitle' + + // Possible values for version are: 'development', 'experimental', 'dolibarr', 'dolibarr_deprecated', 'experimental_deprecated' or a version string like 'x.y.z' + $this->version = '1.0'; + // Url to the file with your last numberversion of this module + //$this->url_last_version = 'http://www.example.com/versionmodule.txt'; + + // Key used in llx_const table to save module status enabled/disabled (where SUBTOTALTITLE is value of property name of module in uppercase) + $this->const_name = 'MAIN_MODULE_'.strtoupper($this->name); + + // Name of image file used for this module. + // If file is in theme/yourtheme/img directory under name object_pictovalue.png, use this->picto='pictovalue' + // If file is in module/img directory under name object_pictovalue.png, use this->picto='pictovalue@module' + // To use a supported fa-xxx css style of font awesome, use this->picto='xxx' + $this->picto = 'fa-layer-group'; + + // Define some features supported by module (triggers, login, substitutions, menus, css, etc...) + $this->module_parts = array( + // Set this to 1 if module has its own trigger directory (core/triggers) + 'triggers' => 1, + // Set this to 1 if module has its own login method file (core/login) + 'login' => 0, + // Set this to 1 if module has its own substitution function file (core/substitutions) + 'substitutions' => 1, + // Set this to 1 if module has its own menus handler directory (core/menus) + 'menus' => 0, + // Set this to 1 if module overwrite template dir (core/tpl) + 'tpl' => 0, + // Set this to 1 if module has its own barcode directory (core/modules/barcode) + 'barcode' => 0, + // Set this to 1 if module has its own models directory (core/modules/xxx) + 'models' => 0, + // Set this to 1 if module has its own printing directory (core/modules/printing) + 'printing' => 0, + // Set this to 1 if module has its own theme directory (theme) + 'theme' => 0, + // Set this to relative path of css file if module has its own css file + // CSS hinzufügen: + 'css' => array( + '/subtotaltitle/css/subtotaltitle.css' + ), + // JS hinzufügen: + 'js' => array( + '/subtotaltitle/js/subtotaltitle.js' + ), + // Set here all hooks context managed by module. To find available hook context, make a "grep -r '>initHooks(' *" on source code. You can also set hook context to 'all' + /* BEGIN MODULEBUILDER HOOKSCONTEXTS */ + 'hooks' => array( + 'invoicecard', + 'propalcard', + 'ordercard', + ), + /* END MODULEBUILDER HOOKSCONTEXTS */ + // Set this to 1 if features of module are opened to external users + 'moduleforexternal' => 0, + // Set this to 1 if the module provides a website template into doctemplates/websites/website_template-mytemplate + 'websitetemplates' => 0, + // Set this to 1 if the module provides a captcha driver + 'captcha' => 0 + ); + + // Data directories to create when module is enabled. + // Example: this->dirs = array("/subtotaltitle/temp","/subtotaltitle/subdir"); + $this->dirs = array("/subtotaltitle/temp"); + + // Config pages. Put here list of php page, stored into subtotaltitle/admin directory, to use to setup module. + $this->config_page_url = array("setup.php@subtotaltitle"); + + // Dependencies + // A condition to hide module + $this->hidden = getDolGlobalInt('MODULE_SUBTOTALTITLE_DISABLED'); // A condition to disable module; + // List of module class names that must be enabled if this module is enabled. Example: array('always'=>array('modModuleToEnable1','modModuleToEnable2'), 'FR'=>array('modModuleToEnableFR')...) + $this->depends = array(); + // List of module class names to disable if this one is disabled. Example: array('modModuleToDisable1', ...) + $this->requiredby = array(); + // List of module class names this module is in conflict with. Example: array('modModuleToDisable1', ...) + $this->conflictwith = array(); + + // The language file dedicated to your module + $this->langfiles = array("subtotaltitle@subtotaltitle"); + + // Prerequisites + $this->phpmin = array(7, 1); // Minimum version of PHP required by module + // $this->phpmax = array(8, 0); // Maximum version of PHP required by module + $this->need_dolibarr_version = array(19, -3); // Minimum version of Dolibarr required by module + // $this->max_dolibarr_version = array(19, -3); // Maximum version of Dolibarr required by module + $this->need_javascript_ajax = 0; + + // Messages at activation + $this->warnings_activation = array(); // Warning to show when we activate module. array('always'='text') or array('FR'='textfr','MX'='textmx'...) + $this->warnings_activation_ext = array(); // Warning to show when we activate an external module. array('always'='text') or array('FR'='textfr','MX'='textmx'...) + //$this->automatic_activation = array('FR'=>'SubtotalTitleWasAutomaticallyActivatedBecauseOfYourCountryChoice'); + //$this->always_enabled = true; // If true, can't be disabled + + // Constants + // List of particular constants to add when module is enabled (key, 'chaine', value, desc, visible, 'current' or 'allentities', deleteonunactive) + // Example: $this->const=array(1 => array('SUBTOTALTITLE_MYNEWCONST1', 'chaine', 'myvalue', 'This is a constant to add', 1), + // 2 => array('SUBTOTALTITLE_MYNEWCONST2', 'chaine', 'myvalue', 'This is another constant to add', 0, 'current', 1) + // ); + $this->const = array(); + + // Some keys to add into the overwriting translation tables + /*$this->overwrite_translation = array( + 'en_US:ParentCompany'=>'Parent company or reseller', + 'fr_FR:ParentCompany'=>'Maison mère ou revendeur' + )*/ + + if (!isModEnabled("subtotaltitle")) { + $conf->subtotaltitle = new stdClass(); + $conf->subtotaltitle->enabled = 0; + } + + // Array to add new pages in new tabs + /* BEGIN MODULEBUILDER TABS */ + $this->tabs = array(); + /* END MODULEBUILDER TABS */ + // Example: + // To add a new tab identified by code tabname1 + // $this->tabs[] = array('data' => 'objecttype:+tabname1:Title1:mylangfile@subtotaltitle:$user->hasRight(\'subtotaltitle\', \'read\'):/subtotaltitle/mynewtab1.php?id=__ID__'); + // To add another new tab identified by code tabname2. Label will be result of calling all substitution functions on 'Title2' key. + // $this->tabs[] = array('data' => 'objecttype:+tabname2:SUBSTITUTION_Title2:mylangfile@subtotaltitle:$user->hasRight(\'othermodule\', \'read\'):/subtotaltitle/mynewtab2.php?id=__ID__', + // To remove an existing tab identified by code tabname + // $this->tabs[] = array('data' => 'objecttype:-tabname:NU:conditiontoremove'); + // + // Where objecttype can be + // 'categories_x' to add a tab in category view (replace 'x' by type of category (0=product, 1=supplier, 2=customer, 3=member) + // 'contact' to add a tab in contact view + // 'contract' to add a tab in contract view + // 'delivery' to add a tab in delivery view + // 'group' to add a tab in group view + // 'intervention' to add a tab in intervention view + // 'invoice' to add a tab in customer invoice view + // 'supplier_invoice' to add a tab in supplier invoice view + // 'member' to add a tab in foundation member view + // 'opensurveypoll' to add a tab in opensurvey poll view + // 'order' to add a tab in sale order view + // 'supplier_order' to add a tab in supplier order view + // 'payment' to add a tab in payment view + // 'supplier_payment' to add a tab in supplier payment view + // 'product' to add a tab in product view + // 'propal' to add a tab in propal view + // 'project' to add a tab in project view + // 'stock' to add a tab in stock view + // 'thirdparty' to add a tab in third party view + // 'user' to add a tab in user view + + + // Dictionaries + /* Example: + $this->dictionaries=array( + 'langs' => 'subtotaltitle@subtotaltitle', + // List of tables we want to see into dictionary editor + 'tabname' => array("table1", "table2", "table3"), + // Label of tables + 'tablib' => array("Table1", "Table2", "Table3"), + // Request to select fields + 'tabsql' => array('SELECT f.rowid as rowid, f.code, f.label, f.active FROM '.$this->db->prefix().'table1 as f', 'SELECT f.rowid as rowid, f.code, f.label, f.active FROM '.$this->db->prefix().'table2 as f', 'SELECT f.rowid as rowid, f.code, f.label, f.active FROM '.$this->db->prefix().'table3 as f'), + // Sort order + 'tabsqlsort' => array("label ASC", "label ASC", "label ASC"), + // List of fields (result of select to show dictionary) + 'tabfield' => array("code,label", "code,label", "code,label"), + // List of fields (list of fields to edit a record) + 'tabfieldvalue' => array("code,label", "code,label", "code,label"), + // List of fields (list of fields for insert) + 'tabfieldinsert' => array("code,label", "code,label", "code,label"), + // Name of columns with primary key (try to always name it 'rowid') + 'tabrowid' => array("rowid", "rowid", "rowid"), + // Condition to show each dictionary + 'tabcond' => array(isModEnabled('subtotaltitle'), isModEnabled('subtotaltitle'), isModEnabled('subtotaltitle')), + // Tooltip for every fields of dictionaries: DO NOT PUT AN EMPTY ARRAY + 'tabhelp' => array(array('code' => $langs->trans('CodeTooltipHelp'), 'field2' => 'field2tooltip'), array('code' => $langs->trans('CodeTooltipHelp'), 'field2' => 'field2tooltip'), ...), + ); + */ + /* BEGIN MODULEBUILDER DICTIONARIES */ + $this->dictionaries = array(); + /* END MODULEBUILDER DICTIONARIES */ + + // Boxes/Widgets + // Add here list of php file(s) stored in subtotaltitle/core/boxes that contains a class to show a widget. + /* BEGIN MODULEBUILDER WIDGETS */ + $this->boxes = array( + // 0 => array( + // 'file' => 'subtotaltitlewidget1.php@subtotaltitle', + // 'note' => 'Widget provided by SubtotalTitle', + // 'enabledbydefaulton' => 'Home', + // ), + // ... + ); + /* END MODULEBUILDER WIDGETS */ + + // Cronjobs (List of cron jobs entries to add when module is enabled) + // unit_frequency must be 60 for minute, 3600 for hour, 86400 for day, 604800 for week + /* BEGIN MODULEBUILDER CRON */ + $this->cronjobs = array( + // 0 => array( + // 'label' => 'MyJob label', + // 'jobtype' => 'method', + // 'class' => '/subtotaltitle/class/myobject.class.php', + // 'objectname' => 'MyObject', + // 'method' => 'doScheduledJob', + // 'parameters' => '', + // 'comment' => 'Comment', + // 'frequency' => 2, + // 'unitfrequency' => 3600, + // 'status' => 0, + // 'test' => 'isModEnabled("subtotaltitle")', + // 'priority' => 50, + // ), + ); + /* END MODULEBUILDER CRON */ + // Example: $this->cronjobs=array( + // 0=>array('label'=>'My label', 'jobtype'=>'method', 'class'=>'/dir/class/file.class.php', 'objectname'=>'MyClass', 'method'=>'myMethod', 'parameters'=>'param1, param2', 'comment'=>'Comment', 'frequency'=>2, 'unitfrequency'=>3600, 'status'=>0, 'test'=>'isModEnabled("subtotaltitle")', 'priority'=>50), + // 1=>array('label'=>'My label', 'jobtype'=>'command', 'command'=>'', 'parameters'=>'param1, param2', 'comment'=>'Comment', 'frequency'=>1, 'unitfrequency'=>3600*24, 'status'=>0, 'test'=>'isModEnabled("subtotaltitle")', 'priority'=>50) + // ); + + // Permissions provided by this module + $this->rights = array(); + $r = 0; + // Add here entries to declare new permissions + /* BEGIN MODULEBUILDER PERMISSIONS */ + /* + $o = 1; + $this->rights[$r][0] = $this->numero . sprintf("%02d", ($o * 10) + 1); // Permission id (must not be already used) + $this->rights[$r][1] = 'Read objects of SubtotalTitle'; // Permission label + $this->rights[$r][4] = 'myobject'; + $this->rights[$r][5] = 'read'; // In php code, permission will be checked by test if ($user->hasRight('subtotaltitle', 'myobject', 'read')) + $r++; + $this->rights[$r][0] = $this->numero . sprintf("%02d", ($o * 10) + 2); // Permission id (must not be already used) + $this->rights[$r][1] = 'Create/Update objects of SubtotalTitle'; // Permission label + $this->rights[$r][4] = 'myobject'; + $this->rights[$r][5] = 'write'; // In php code, permission will be checked by test if ($user->hasRight('subtotaltitle', 'myobject', 'write')) + $r++; + $this->rights[$r][0] = $this->numero . sprintf("%02d", ($o * 10) + 3); // Permission id (must not be already used) + $this->rights[$r][1] = 'Delete objects of SubtotalTitle'; // Permission label + $this->rights[$r][4] = 'myobject'; + $this->rights[$r][5] = 'delete'; // In php code, permission will be checked by test if ($user->hasRight('subtotaltitle', 'myobject', 'delete')) + $r++; + */ + /* END MODULEBUILDER PERMISSIONS */ + + + // Main menu entries to add + $this->menu = array(); + $r = 0; + // Add here entries to declare new menus + /* BEGIN MODULEBUILDER TOPMENU */ + // MENÜ-EINTRAG DEAKTIVIERT - Modul arbeitet im Hintergrund + /* + $this->menu[$r++] = array( + 'fk_menu' => '', // Will be stored into mainmenu + leftmenu. Use '' if this is a top menu. For left menu, use 'fk_mainmenu=xxx' or 'fk_mainmenu=xxx,fk_leftmenu=yyy' where xxx is mainmenucode and yyy is a leftmenucode + 'type' => 'top', // This is a Top menu entry + 'titre' => 'ModuleSubtotalTitleName', + 'prefix' => img_picto('', $this->picto, 'class="pictofixedwidth valignmiddle"'), + 'mainmenu' => 'subtotaltitle', + 'leftmenu' => '', + 'url' => '/subtotaltitle/subtotaltitleindex.php', + 'langs' => 'subtotaltitle@subtotaltitle', // Lang file to use (without .lang) by module. File must be in langs/code_CODE/ directory. + 'position' => 1000 + $r, + 'enabled' => 'isModEnabled("subtotaltitle")', // Define condition to show or hide menu entry. Use 'isModEnabled("subtotaltitle")' if entry must be visible if module is enabled. + 'perms' => '1', // Use 'perms'=>'$user->hasRight("subtotaltitle", "myobject", "read")' if you want your menu with a permission rules + 'target' => '', + 'user' => 2, // 0=Menu for internal users, 1=external users, 2=both + ); + */ + /* END MODULEBUILDER TOPMENU */ + + /* BEGIN MODULEBUILDER LEFTMENU MYOBJECT */ + /* + $this->menu[$r++]=array( + 'fk_menu' => 'fk_mainmenu=subtotaltitle', // '' if this is a top menu. For left menu, use 'fk_mainmenu=xxx' or 'fk_mainmenu=xxx,fk_leftmenu=yyy' where xxx is mainmenucode and yyy is a leftmenucode + 'type' => 'left', // This is a Left menu entry + 'titre' => 'MyObject', + 'prefix' => img_picto('', $this->picto, 'class="pictofixedwidth valignmiddle paddingright"'), + 'mainmenu' => 'subtotaltitle', + 'leftmenu' => 'myobject', + 'url' => '/subtotaltitle/subtotaltitleindex.php', + 'langs' => 'subtotaltitle@subtotaltitle', // Lang file to use (without .lang) by module. File must be in langs/code_CODE/ directory. + 'position' => 1000 + $r, + 'enabled' => 'isModEnabled("subtotaltitle")', // Define condition to show or hide menu entry. Use 'isModEnabled("subtotaltitle")' if entry must be visible if module is enabled. + 'perms' => '$user->hasRight("subtotaltitle", "myobject", "read")', + 'target' => '', + 'user' => 2, // 0=Menu for internal users, 1=external users, 2=both + 'object' => 'MyObject' + ); + $this->menu[$r++]=array( + 'fk_menu' => 'fk_mainmenu=subtotaltitle,fk_leftmenu=myobject', // '' if this is a top menu. For left menu, use 'fk_mainmenu=xxx' or 'fk_mainmenu=xxx,fk_leftmenu=yyy' where xxx is mainmenucode and yyy is a leftmenucode + 'type' => 'left', // This is a Left menu entry + 'titre' => 'New_MyObject', + 'mainmenu' => 'subtotaltitle', + 'leftmenu' => 'subtotaltitle_myobject_new', + 'url' => '/subtotaltitle/myobject_card.php?action=create', + 'langs' => 'subtotaltitle@subtotaltitle', // Lang file to use (without .lang) by module. File must be in langs/code_CODE/ directory. + 'position' => 1000 + $r, + 'enabled' => 'isModEnabled("subtotaltitle")', // Define condition to show or hide menu entry. Use 'isModEnabled("subtotaltitle")' if entry must be visible if module is enabled. Use '$leftmenu==\'system\'' to show if leftmenu system is selected. + 'perms' => '$user->hasRight("subtotaltitle", "myobject", "write")' + 'target' => '', + 'user' => 2, // 0=Menu for internal users, 1=external users, 2=both + 'object' => 'MyObject' + ); + $this->menu[$r++]=array( + 'fk_menu' => 'fk_mainmenu=subtotaltitle,fk_leftmenu=myobject', // '' if this is a top menu. For left menu, use 'fk_mainmenu=xxx' or 'fk_mainmenu=xxx,fk_leftmenu=yyy' where xxx is mainmenucode and yyy is a leftmenucode + 'type' => 'left', // This is a Left menu entry + 'titre' => 'List_MyObject', + 'mainmenu' => 'subtotaltitle', + 'leftmenu' => 'subtotaltitle_myobject_list', + 'url' => '/subtotaltitle/myobject_list.php', + 'langs' => 'subtotaltitle@subtotaltitle', // Lang file to use (without .lang) by module. File must be in langs/code_CODE/ directory. + 'position' => 1000 + $r, + 'enabled' => 'isModEnabled("subtotaltitle")', // Define condition to show or hide menu entry. Use 'isModEnabled("subtotaltitle")' if entry must be visible if module is enabled. + 'perms' => '$user->hasRight("subtotaltitle", "myobject", "read")' + 'target' => '', + 'user' => 2, // 0=Menu for internal users, 1=external users, 2=both + 'object' => 'MyObject' + ); + */ + /* END MODULEBUILDER LEFTMENU MYOBJECT */ + + + // Exports profiles provided by this module + $r = 0; + /* BEGIN MODULEBUILDER EXPORT MYOBJECT */ + /* + $langs->load("subtotaltitle@subtotaltitle"); + $this->export_code[$r] = $this->rights_class.'_'.$r; + $this->export_label[$r] = 'MyObjectLines'; // Translation key (used only if key ExportDataset_xxx_z not found) + $this->export_icon[$r] = $this->picto; + // Define $this->export_fields_array, $this->export_TypeFields_array and $this->export_entities_array + $keyforclass = 'MyObject'; $keyforclassfile='/subtotaltitle/class/myobject.class.php'; $keyforelement='myobject@subtotaltitle'; + include DOL_DOCUMENT_ROOT.'/core/commonfieldsinexport.inc.php'; + //$this->export_fields_array[$r]['t.fieldtoadd']='FieldToAdd'; $this->export_TypeFields_array[$r]['t.fieldtoadd']='Text'; + //unset($this->export_fields_array[$r]['t.fieldtoremove']); + //$keyforclass = 'MyObjectLine'; $keyforclassfile='/subtotaltitle/class/myobject.class.php'; $keyforelement='myobjectline@subtotaltitle'; $keyforalias='tl'; + //include DOL_DOCUMENT_ROOT.'/core/commonfieldsinexport.inc.php'; + $keyforselect='myobject'; $keyforaliasextra='extra'; $keyforelement='myobject@subtotaltitle'; + include DOL_DOCUMENT_ROOT.'/core/extrafieldsinexport.inc.php'; + //$keyforselect='myobjectline'; $keyforaliasextra='extraline'; $keyforelement='myobjectline@subtotaltitle'; + //include DOL_DOCUMENT_ROOT.'/core/extrafieldsinexport.inc.php'; + //$this->export_dependencies_array[$r] = array('myobjectline' => array('tl.rowid','tl.ref')); // To force to activate one or several fields if we select some fields that need same (like to select a unique key if we ask a field of a child to avoid the DISTINCT to discard them, or for computed field than need several other fields) + //$this->export_special_array[$r] = array('t.field' => '...'); + //$this->export_examplevalues_array[$r] = array('t.field' => 'Example'); + //$this->export_help_array[$r] = array('t.field' => 'FieldDescHelp'); + $this->export_sql_start[$r]='SELECT DISTINCT '; + $this->export_sql_end[$r] =' FROM '.$this->db->prefix().'subtotaltitle_myobject as t'; + //$this->export_sql_end[$r] .=' LEFT JOIN '.$this->db->prefix().'subtotaltitle_myobject_line as tl ON tl.fk_myobject = t.rowid'; + $this->export_sql_end[$r] .=' WHERE 1 = 1'; + $this->export_sql_end[$r] .=' AND t.entity IN ('.getEntity('myobject').')'; + $r++; */ + /* END MODULEBUILDER EXPORT MYOBJECT */ + + // Imports profiles provided by this module + $r = 0; + /* BEGIN MODULEBUILDER IMPORT MYOBJECT */ + /* + $langs->load("subtotaltitle@subtotaltitle"); + $this->import_code[$r] = $this->rights_class.'_'.$r; + $this->import_label[$r] = 'MyObjectLines'; // Translation key (used only if key ExportDataset_xxx_z not found) + $this->import_icon[$r] = $this->picto; + $this->import_tables_array[$r] = array('t' => $this->db->prefix().'subtotaltitle_myobject', 'extra' => $this->db->prefix().'subtotaltitle_myobject_extrafields'); + $this->import_tables_creator_array[$r] = array('t' => 'fk_user_author'); // Fields to store import user id + $import_sample = array(); + $keyforclass = 'MyObject'; $keyforclassfile='/subtotaltitle/class/myobject.class.php'; $keyforelement='myobject@subtotaltitle'; + include DOL_DOCUMENT_ROOT.'/core/commonfieldsinimport.inc.php'; + $import_extrafield_sample = array(); + $keyforselect='myobject'; $keyforaliasextra='extra'; $keyforelement='myobject@subtotaltitle'; + include DOL_DOCUMENT_ROOT.'/core/extrafieldsinimport.inc.php'; + $this->import_fieldshidden_array[$r] = array('extra.fk_object' => 'lastrowid-'.$this->db->prefix().'subtotaltitle_myobject'); + $this->import_regex_array[$r] = array(); + $this->import_examplevalues_array[$r] = array_merge($import_sample, $import_extrafield_sample); + $this->import_updatekeys_array[$r] = array('t.ref' => 'Ref'); + $this->import_convertvalue_array[$r] = array( + 't.ref' => array( + 'rule'=>'getrefifauto', + 'class'=>(!getDolGlobalString('SUBTOTALTITLE_MYOBJECT_ADDON') ? 'mod_myobject_standard' : getDolGlobalString('SUBTOTALTITLE_MYOBJECT_ADDON')), + 'path'=>"/core/modules/subtotaltitle/".(!getDolGlobalString('SUBTOTALTITLE_MYOBJECT_ADDON') ? 'mod_myobject_standard' : getDolGlobalString('SUBTOTALTITLE_MYOBJECT_ADDON')).'.php', + 'classobject'=>'MyObject', + 'pathobject'=>'/subtotaltitle/class/myobject.class.php', + ), + 't.fk_soc' => array('rule' => 'fetchidfromref', 'file' => '/societe/class/societe.class.php', 'class' => 'Societe', 'method' => 'fetch', 'element' => 'ThirdParty'), + 't.fk_user_valid' => array('rule' => 'fetchidfromref', 'file' => '/user/class/user.class.php', 'class' => 'User', 'method' => 'fetch', 'element' => 'user'), + 't.fk_mode_reglement' => array('rule' => 'fetchidfromcodeorlabel', 'file' => '/compta/paiement/class/cpaiement.class.php', 'class' => 'Cpaiement', 'method' => 'fetch', 'element' => 'cpayment'), + ); + $this->import_run_sql_after_array[$r] = array(); + $r++; */ + /* END MODULEBUILDER IMPORT MYOBJECT */ + } + + /** + * Function called when module is enabled. + * The init function add constants, boxes, permissions and menus (defined in constructor) into Dolibarr database. + * It also creates data directories + * + * @param string $options Options when enabling module ('', 'noboxes') + * @return int<-1,1> 1 if OK, <=0 if KO + */ + public function init($options = '') + { + global $conf, $langs; + + // Create tables of module at module activation + //$result = $this->_load_tables('/install/mysql/', 'subtotaltitle'); + $result = $this->_load_tables('/subtotaltitle/sql/'); + if ($result < 0) { + return -1; // Do not activate module if error 'not allowed' returned when loading module SQL queries (the _load_table run sql with run_sql with the error allowed parameter set to 'default') + } + + // Create extrafields during init + //include_once DOL_DOCUMENT_ROOT.'/core/class/extrafields.class.php'; + //$extrafields = new ExtraFields($this->db); + //$result0=$extrafields->addExtraField('subtotaltitle_separator1', "Separator 1", 'separator', 1, 0, 'thirdparty', 0, 0, '', array('options'=>array(1=>1)), 1, '', 1, 0, '', '', 'subtotaltitle@subtotaltitle', 'isModEnabled("subtotaltitle")'); + //$result1=$extrafields->addExtraField('subtotaltitle_myattr1', "New Attr 1 label", 'boolean', 1, 3, 'thirdparty', 0, 0, '', '', 1, '', -1, 0, '', '', 'subtotaltitle@subtotaltitle', 'isModEnabled("subtotaltitle")'); + //$result2=$extrafields->addExtraField('subtotaltitle_myattr2', "New Attr 2 label", 'varchar', 1, 10, 'project', 0, 0, '', '', 1, '', -1, 0, '', '', 'subtotaltitle@subtotaltitle', 'isModEnabled("subtotaltitle")'); + //$result3=$extrafields->addExtraField('subtotaltitle_myattr3', "New Attr 3 label", 'varchar', 1, 10, 'bank_account', 0, 0, '', '', 1, '', -1, 0, '', '', 'subtotaltitle@subtotaltitle', 'isModEnabled("subtotaltitle")'); + //$result4=$extrafields->addExtraField('subtotaltitle_myattr4', "New Attr 4 label", 'select', 1, 3, 'thirdparty', 0, 1, '', array('options'=>array('code1'=>'Val1','code2'=>'Val2','code3'=>'Val3')), 1,'', -1, 0, '', '', 'subtotaltitle@subtotaltitle', 'isModEnabled("subtotaltitle")'); + //$result5=$extrafields->addExtraField('subtotaltitle_myattr5', "New Attr 5 label", 'text', 1, 10, 'user', 0, 0, '', '', 1, '', -1, 0, '', '', 'subtotaltitle@subtotaltitle', 'isModEnabled("subtotaltitle")'); + + // Permissions + $this->remove($options); + + $sql = array(); + + // Document templates + $moduledir = dol_sanitizeFileName('subtotaltitle'); + $myTmpObjects = array(); + $myTmpObjects['MyObject'] = array('includerefgeneration' => 0, 'includedocgeneration' => 0); + + foreach ($myTmpObjects as $myTmpObjectKey => $myTmpObjectArray) { + if ($myTmpObjectArray['includerefgeneration']) { + $src = DOL_DOCUMENT_ROOT.'/install/doctemplates/'.$moduledir.'/template_myobjects.odt'; + $dirodt = DOL_DATA_ROOT.($conf->entity > 1 ? '/'.$conf->entity : '').'/doctemplates/'.$moduledir; + $dest = $dirodt.'/template_myobjects.odt'; + + if (file_exists($src) && !file_exists($dest)) { + require_once DOL_DOCUMENT_ROOT.'/core/lib/files.lib.php'; + dol_mkdir($dirodt); + $result = dol_copy($src, $dest, '0', 0); + if ($result < 0) { + $langs->load("errors"); + $this->error = $langs->trans('ErrorFailToCopyFile', $src, $dest); + return 0; + } + } + + $sql = array_merge($sql, array( + "DELETE FROM ".$this->db->prefix()."document_model WHERE nom = 'standard_".strtolower($myTmpObjectKey)."' AND type = '".$this->db->escape(strtolower($myTmpObjectKey))."' AND entity = ".((int) $conf->entity), + "INSERT INTO ".$this->db->prefix()."document_model (nom, type, entity) VALUES('standard_".strtolower($myTmpObjectKey)."', '".$this->db->escape(strtolower($myTmpObjectKey))."', ".((int) $conf->entity).")", + "DELETE FROM ".$this->db->prefix()."document_model WHERE nom = 'generic_".strtolower($myTmpObjectKey)."_odt' AND type = '".$this->db->escape(strtolower($myTmpObjectKey))."' AND entity = ".((int) $conf->entity), + "INSERT INTO ".$this->db->prefix()."document_model (nom, type, entity) VALUES('generic_".strtolower($myTmpObjectKey)."_odt', '".$this->db->escape(strtolower($myTmpObjectKey))."', ".((int) $conf->entity).")" + )); + } + } + + return $this->_init($sql, $options); + } + + /** + * Function called when module is disabled. + * Remove from database constants, boxes and permissions from Dolibarr database. + * Data directories are not deleted + * + * @param string $options Options when enabling module ('', 'noboxes') + * @return int<-1,1> 1 if OK, <=0 if KO + */ + public function remove($options = '') + { + $sql = array(); + return $this->_remove($sql, $options); + } +} diff --git a/core/substitutions/functions_subtotaltitle.lib.php b/core/substitutions/functions_subtotaltitle.lib.php new file mode 100755 index 0000000..c49190a --- /dev/null +++ b/core/substitutions/functions_subtotaltitle.lib.php @@ -0,0 +1,102 @@ + + * + * 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. + */ + +/** + * \file subtotaltitle/core/substitutions/functions_subtotaltitle.lib.php + * \brief Substitution functions for SubtotalTitle module (ODT templates) + * + * Stellt folgende ODT-Variablen bereit: + * - {line_special_code} : Der numerische Code (0, 100, 101, 102) + * - {line_is_section} : "1" wenn Section, sonst leer + * - {line_is_textline} : "1" wenn Textzeile, sonst leer + * - {line_is_subtotal} : "1" wenn Zwischensumme, sonst leer + * - {line_is_product} : "1" wenn Produkt, sonst leer + * - {line_is_normal} : "1" wenn special_code = 0, sonst leer + * - {line_is_special} : "1" wenn eine Spezialzeile (100-102), sonst leer + */ + +/** + * Function called for each line to add custom substitution tags + * + * @param array $substitutionarray Array with substitution key=>val + * @param Translate $langs Output langs + * @param Object $object Object to use to get values + * @param Object $line Current line being processed + * @return void + */ +function subtotaltitle_completesubstitutionarray_lines(&$substitutionarray, $langs, $object, $line) +{ + global $conf, $db; + + // special_code direkt verfügbar machen + $special_code = isset($line->special_code) ? (int)$line->special_code : 0; + $substitutionarray['line_special_code'] = $special_code; + + // Typ-Flags für einfachere IF-Bedingungen im ODT + // Werte: '1' für true, '' (leer) für false - so funktioniert [!-- IF {xxx} --] + + // Section/Titel (special_code = 100) + $substitutionarray['line_is_section'] = ($special_code == 100) ? '1' : ''; + + // Textzeile (special_code = 101) + $substitutionarray['line_is_textline'] = ($special_code == 101) ? '1' : ''; + + // Zwischensumme (special_code = 102) + $substitutionarray['line_is_subtotal'] = ($special_code == 102) ? '1' : ''; + + // Normales Produkt (special_code = 0 und hat Produkt-Referenz) + $is_product = ($special_code == 0 && isset($line->fk_product) && $line->fk_product > 0); + $substitutionarray['line_is_product'] = $is_product ? '1' : ''; + + // Freie Zeile (special_code = 0 und kein Produkt) + $is_free_line = ($special_code == 0 && (!isset($line->fk_product) || $line->fk_product <= 0)); + $substitutionarray['line_is_free_line'] = $is_free_line ? '1' : ''; + + // Kombinierter Check: Ist es KEINE unserer Spezialzeilen? + $is_normal = ($special_code == 0); + $substitutionarray['line_is_normal'] = $is_normal ? '1' : ''; + + // Ist es EINE unserer Spezialzeilen? + $is_special = ($special_code >= 100 && $special_code <= 102); + $substitutionarray['line_is_special'] = $is_special ? '1' : ''; +} + +/** + * Function called once for global substitutions (not per line) + * + * @param array $substitutionarray Array with substitution key=>val + * @param Translate $langs Output langs + * @param Object $object Object to use to get values + * @return void + */ +function subtotaltitle_completesubstitutionarray(&$substitutionarray, $langs, $object) +{ + global $conf, $db; + + // Zähle Sections, Textzeilen und Subtotals in diesem Dokument + if (is_object($object) && isset($object->lines) && is_array($object->lines)) { + $count_sections = 0; + $count_textlines = 0; + $count_subtotals = 0; + + foreach ($object->lines as $line) { + $special_code = isset($line->special_code) ? (int)$line->special_code : 0; + if ($special_code == 100) $count_sections++; + if ($special_code == 101) $count_textlines++; + if ($special_code == 102) $count_subtotals++; + } + + $substitutionarray['object_count_sections'] = $count_sections; + $substitutionarray['object_count_textlines'] = $count_textlines; + $substitutionarray['object_count_subtotals'] = $count_subtotals; + $substitutionarray['object_has_sections'] = ($count_sections > 0) ? '1' : ''; + $substitutionarray['object_has_textlines'] = ($count_textlines > 0) ? '1' : ''; + $substitutionarray['object_has_speciallines'] = ($count_sections > 0 || $count_textlines > 0 || $count_subtotals) ? '1' : ''; + } +} diff --git a/css/subtotaltitle.css b/css/subtotaltitle.css new file mode 100755 index 0000000..ebf5a7a --- /dev/null +++ b/css/subtotaltitle.css @@ -0,0 +1,117 @@ +/* Drag-Feedback */ +.myDragClass { + background-color: #ffffcc !important; + opacity: 0.8; + cursor: move !important; +} + +/* Drag-Handle sichtbar machen */ +.linecolmove { + cursor: move; +} + +/* Section-Header nicht ziehbar */ +tr.section-header { + cursor: default !important; +} + +tr.section-header .linecolmove { + cursor: default !important; + pointer-events: none; +} + +/* Subtotal-Zeile nicht ziehbar */ +tr.subtotal-row .linecolmove { + cursor: default !important; + pointer-events: none; +} + +/* ========== COLLAPSE FUNKTIONEN ========== */ + +/* Eingeklappte Produkte verstecken */ +tr.section-collapsed { + display: none; +} + +/* Section-Header Collapse-Icon */ +.section-toggle { + cursor: pointer; + margin-right: 8px; + user-select: none; +} + +/* Section-Header Buttons mehr Abstand */ +tr.section-header a { + padding: 5px 8px; + display: inline-block; +} + +/* Produkt-Anzahl Badge */ +.section-count { + background: #666; + color: white; + padding: 2px 8px; + border-radius: 10px; + font-size: 11px; + margin-left: 10px; +} + +/* Collapse-Buttons unten */ +.section-collapse-buttons { + margin-top: 10px; + margin-bottom: 10px; +} + +.section-collapse-buttons a { + margin-right: 15px; +} + +/* Collapse-Buttons Zeile */ +.section-collapse-actions { + margin-top: 5px; +} + +/* Textzeilen - nur am Handle ziehbar */ +tr.textline-row { + cursor: default; +} + +tr.textline-row .linecolmove { + cursor: move; +} + +tr.textline-row:hover { + background-color: #f5f5f5 !important; +} + +/* Section-Zugehörigkeit optisch hervorheben */ +tr[data-parent-section] { + border-left: 3px solid #4a90d9 !important; +} +/* ========== SECTION-ZUGEHÖRIGKEIT ========== */ + +/* Freie Produkte - kein Rand */ +#tablelines tr[data-parent-section="null"] > td:first-child { +border-left: none !important; +} + +/* Section-Header Basis-Style - erbt vom Theme */ +#tablelines tr.section-header > td { +background-color: inherit; +} + +/* ========== SYNC + COLLAPSE BUTTONS ZEILE ========== */ +.sync-collapse-row { + text-align: right; + padding: 5px 0; +} + +.sync-collapse-row a { + margin-left: 5px; +} + +/* Massenlösch-Button visuell abgetrennt */ +#btnMassDelete { + border-left: 2px solid #ccc; + padding-left: 15px !important; +} diff --git a/css/subtotaltitle_sync.css b/css/subtotaltitle_sync.css new file mode 100755 index 0000000..112ba42 --- /dev/null +++ b/css/subtotaltitle_sync.css @@ -0,0 +1,46 @@ +/* ========================================== + SUBTOTALTITLE SYNC CSS + Diese Zeilen zu deiner subtotaltitle.css hinzufügen + ========================================== */ + +/* Zeilen die in facturedet sind - grüner Rand links */ +tr.in-facturedet > td:first-child { + border-left: 3px solid #28a745 !important; +} + +tr.in-facturedet { + background-color: rgba(40, 167, 69, 0.05) !important; +} + +/* Section-Header in facturedet */ +tr.section-header.in-facturedet { + background-color: rgba(40, 167, 69, 0.1) !important; +} + +/* Subtotal in facturedet */ +tr.subtotal-row.in-facturedet { + background-color: rgba(40, 167, 69, 0.15) !important; +} + +/* Sync-Checkbox Styling */ +.sync-checkbox { + cursor: pointer; + margin-right: 3px; + vertical-align: middle; +} + +.sync-checkbox:hover { + transform: scale(1.2); +} + +/* Sync-Buttons in der Action-Bar */ +.tabsAction a[onclick*="syncAllToFacturedet"], +.tabsAction a[onclick*="removeAllFromFacturedet"] { + margin-left: 5px; +} + +/* Visueller Hinweis für "zur Rechnung" */ +.sync-checkbox:checked + span, +.sync-checkbox:checked ~ span { + color: #28a745; +} diff --git a/debug_sections.php b/debug_sections.php new file mode 100644 index 0000000..80dcbb8 --- /dev/null +++ b/debug_sections.php @@ -0,0 +1,64 @@ +Debug: Sections für Order ID $order_id"; + +// SQL-Abfrage 1: Alle Einträge für diese Order +$sql1 = "SELECT * FROM ".MAIN_DB_PREFIX."facture_lines_manager"; +$sql1 .= " WHERE ".$tables['fk_parent']." = ".(int)$order_id; +$sql1 .= " ORDER BY line_order"; + +echo "

SQL 1: Alle Einträge

"; +echo "
".htmlspecialchars($sql1)."
"; + +$resql1 = $db->query($sql1); +if ($resql1) { + echo ""; + while ($obj = $db->fetch_object($resql1)) { + echo ""; + echo ""; + echo ""; + echo ""; + echo ""; + echo ""; + echo ""; + echo ""; + } + echo "
rowidfk_commandedocument_typeline_typetitleline_order
".$obj->rowid."".$obj->fk_commande."".$obj->document_type."".$obj->line_type."".$obj->title."".$obj->line_order."
"; +} else { + echo "

Fehler: ".$db->lasterror()."

"; +} + +// SQL-Abfrage 2: Nur Sections mit document_type +$sql2 = "SELECT * FROM ".MAIN_DB_PREFIX."facture_lines_manager"; +$sql2 .= " WHERE ".$tables['fk_parent']." = ".(int)$order_id; +$sql2 .= " AND document_type = '".$db->escape($docType)."'"; +$sql2 .= " AND line_type = 'section'"; +$sql2 .= " ORDER BY line_order"; + +echo "

SQL 2: Sections mit document_type = 'order'

"; +echo "
".htmlspecialchars($sql2)."
"; + +$resql2 = $db->query($sql2); +if ($resql2) { + $num = $db->num_rows($resql2); + echo "

Anzahl gefunden: $num

"; + echo ""; + while ($obj = $db->fetch_object($resql2)) { + echo ""; + echo ""; + echo ""; + echo ""; + echo ""; + } + echo "
rowidtitleline_order
".$obj->rowid."".$obj->title."".$obj->line_order."
"; +} else { + echo "

Fehler: ".$db->lasterror()."

"; +} diff --git a/img/README.md b/img/README.md new file mode 100755 index 0000000..c1727dd --- /dev/null +++ b/img/README.md @@ -0,0 +1,14 @@ + +Directory for module image files +-------------------------------- + +You can put here the .png files of your module: + + +If the picto of your module is an image (property $picto has been set to 'subtotaltitle.png@subtotaltitle', you can put into this +directory a .png file called *object_subtotaltitle.png* (16x16 or 32x32 pixels) + + +If the picto of an object is an image (property $picto of the object.class.php has been set to 'myobject.png@subtotaltitle', then you can put into this +directory a .png file called *object_myobject.png* (16x16 or 32x32 pixels) + diff --git a/js/mark_line_orders.js b/js/mark_line_orders.js new file mode 100755 index 0000000..63980f5 --- /dev/null +++ b/js/mark_line_orders.js @@ -0,0 +1,24 @@ +// Markiert alle Produktzeilen mit data-line-order +$(document).ready(function() { + var factureId = getFactureId(); + if (!factureId) return; + + $.get('/dolibarr/custom/subtotaltitle/ajax/get_line_orders.php', { + facture_id: factureId + }, function(response) { + if (!response.success) return; + + // Markiere jede Produktzeile + response.lines.forEach(function(line) { + // Finde die Zeile anhand der facturedet ID + var $row = $('#tablelines tbody tr[id*="' + line.fk_facturedet + '"]').first(); + if ($row.length > 0) { + $row.attr('data-line-order', line.line_order); + } + }); + + // Jetzt können wir die leeren Sections einfügen + insertEmptySections(); + + }, 'json'); +}); diff --git a/js/subtotaltitle.js b/js/subtotaltitle.js new file mode 100755 index 0000000..478ac60 --- /dev/null +++ b/js/subtotaltitle.js @@ -0,0 +1,1187 @@ +// DEBUG FLAG - true für Debug-Ausgaben, false für Produktiv +var SUBTOTAL_DEBUG = true; + +function debugLog(message) { + if (SUBTOTAL_DEBUG) { + console.log(message); + } +} + +// Flag: Wird gerade gezogen? +var isDragging = false; +var isTogglingSubtotal = false; + +// Nur einmal laden! +if (typeof SubtotalTitleLoaded === 'undefined') { + SubtotalTitleLoaded = true; + + $(document).ready(function() { + debugLog('✅ SubtotalTitle JS loaded!'); + + // Füge Button zu den Standard-Buttons hinzu - NUR im Entwurfsstatus + if ($('#tablelines').length > 0) { + var factureId = getFactureId(); + + // Prüfe ob Dokument im Entwurfsstatus ist + if (typeof subtotalTitleIsDraft !== 'undefined' && subtotalTitleIsDraft === true) { + var createBtn = '' + (typeof subtotalTitleLang !== 'undefined' ? subtotalTitleLang.sectionCreate || 'Section erstellen' : 'Section erstellen') + ''; + + if ($('.tabsAction').length > 0) { + $('.tabsAction').prepend(createBtn); + debugLog('✅ Section-Button hinzugefügt'); + } + } else { + debugLog('⚠️ Dokument nicht im Entwurfsstatus - Button wird nicht angezeigt'); + } + + // ⬇️ HIER FEHLTE DER AUFRUF! ⬇️ + initDragAndDrop(); + } + }); +} + +/** + * Holt die Dokument-ID und Typ aus der URL + */ +function getDocumentInfo() { + var url = window.location.href; + var match = url.match(/[?&]id=(\d+)/); + var id = match ? match[1] : null; + + // Erkenne Dokumenttyp anhand der URL + var docType = 'invoice'; // Default + if (url.indexOf('/comm/propal/') !== -1) { + docType = 'propal'; + } else if (url.indexOf('/commande/') !== -1) { + docType = 'order'; + } + + return { id: id, type: docType }; +} + +/** + * DEPRECATED: Verwende getDocumentInfo() stattdessen + */ +function getFactureId() { + return getDocumentInfo().id; +} + +/** + * Erstellt eine neue Section + */ +function createNewSection() { + var lang = (typeof subtotalTitleLang !== 'undefined') ? subtotalTitleLang : {}; + var title = prompt(lang.sectionName || 'Neue Positionsgruppe - Name eingeben:'); + if (!title) return; + + var docInfo = getDocumentInfo(); + if (!docInfo.id) { + alert(lang.errorLoadingSections || 'Fehler: Keine Dokument-ID gefunden'); + return; + } + + debugLog('Erstelle Section: ' + title + ' für ' + docInfo.type + ' ID ' + docInfo.id); + + $.post('/dolibarr/custom/subtotaltitle/ajax/create_section.php', { + facture_id: docInfo.id, + document_type: docInfo.type, + title: title + }, function(response) { + debugLog('Section erstellt: ' + JSON.stringify(response)); + if (response.success) { + window.location.href = window.location.pathname + window.location.search; + } else { + alert((lang.errorSavingSection || 'Fehler') + ': ' + (response.error || 'Unbekannter Fehler')); + } + }, 'json').fail(function(xhr, status, error) { + debugLog('AJAX Fehler: ' + status + ' ' + error); + alert((lang.errorSavingSection || 'Fehler beim Erstellen') + ': ' + error); + }); +} + +/** + * Verschiebt eine Section nach oben/unten + */ +function moveSection(sectionId, direction) { + var lang = (typeof subtotalTitleLang !== 'undefined') ? subtotalTitleLang : {}; + debugLog('🔄 Verschiebe Section ' + sectionId + ' ' + direction); + + $.post('/dolibarr/custom/subtotaltitle/ajax/move_section.php', { + section_id: sectionId, + direction: direction + }, function(response) { + debugLog('Move response: ' + JSON.stringify(response)); + if (response.success) { + window.location.href = window.location.pathname + window.location.search; + } else { + alert((lang.errorReordering || 'Fehler') + ': ' + (response.error || 'Unbekannter Fehler')); + } + }, 'json').fail(function(xhr, status, error) { + if (SUBTOTAL_DEBUG) { + console.error('AJAX Fehler: ' + status); + console.error('Response Text: ' + xhr.responseText); + console.error('Error:', error); + } + alert((lang.errorReordering || 'Fehler beim Verschieben') + (SUBTOTAL_DEBUG ? '. Siehe Console (F12) für Details.' : '.')); + }); +} + +/** + * Section umbenennen + */ +function renameSection(sectionId, currentTitle) { + var lang = (typeof subtotalTitleLang !== 'undefined') ? subtotalTitleLang : {}; + var newTitle = prompt(lang.sectionName || 'Positionsgruppe umbenennen:', currentTitle); + if (!newTitle) return; + + debugLog('✏️ Benenne Section ' + sectionId + ' um zu: ' + newTitle); + + $.post('/dolibarr/custom/subtotaltitle/ajax/rename_section.php', { + section_id: sectionId, + title: newTitle + }, function(response) { + debugLog('Rename response: ' + JSON.stringify(response)); + if (response.success) { + window.location.href = window.location.pathname + window.location.search; + } else { + alert((lang.errorSavingSection || 'Fehler') + ': ' + (response.error || 'Unbekannter Fehler')); + } + }, 'json').fail(function(xhr, status, error) { + debugLog('AJAX Fehler: ' + status + ' ' + error); + alert((lang.errorSavingSection || 'Fehler beim Umbenennen') + ': ' + error); + }); +} + +/** + * Löscht eine leere Section + */ +function deleteSection(sectionId) { + var lang = (typeof subtotalTitleLang !== 'undefined') ? subtotalTitleLang : {}; + if (!confirm(lang.confirmDeleteSection || 'Leere Positionsgruppe löschen?')) { + return; + } + + debugLog('🗑️ Lösche leere Section ' + sectionId); + + $.post('/dolibarr/custom/subtotaltitle/ajax/delete_section.php', { + section_id: sectionId, + force: 0 // Nur leere löschen + }, function(response) { + debugLog('Delete response: ' + JSON.stringify(response)); + if (response.success) { + window.location.href = window.location.pathname + window.location.search; + } else { + alert((lang.errorDeletingSection || 'Fehler') + ': ' + (response.error || 'Unbekannter Fehler')); + } + }, 'json').fail(function(xhr, status, error) { + debugLog('AJAX Fehler: ' + status + ' ' + error); + alert((lang.errorDeletingSection || 'Fehler beim Löschen') + ': ' + error); + }); +} + +/** + * Löscht eine Section MIT allen Produkten (Force-Delete) + */ +function deleteSectionForce(sectionId) { + var lang = (typeof subtotalTitleLang !== 'undefined') ? subtotalTitleLang : {}; + var $row = $('tr.section-header[data-section-id="' + sectionId + '"]'); + var productCount = $row.data('product-count'); + var productIds = $row.data('product-ids') || []; + + var msg1 = (lang.confirmDeleteSectionForce || '⚠️ ACHTUNG!\n\nWollen Sie wirklich die Positionsgruppe\nUND alle %s enthaltenen Produkte löschen?\n\nDiese Aktion kann nicht rückgängig gemacht werden!').replace('%s', productCount); + if (!confirm(msg1)) { + return; + } + + var msg2 = (lang.confirmDeleteSectionForce2 || 'Sind Sie WIRKLICH sicher?\n\n%s Produkte werden unwiderruflich gelöscht!').replace('%s', productCount); + if (!confirm(msg2)) { + return; + } + + debugLog('🔴 Force-Delete Section ' + sectionId + ' mit Produkten: ' + JSON.stringify(productIds)); + + $.post('/dolibarr/custom/subtotaltitle/ajax/delete_section.php', { + section_id: sectionId, + force: 1, + product_ids: JSON.stringify(productIds) + }, function(response) { + debugLog('Force-Delete response: ' + JSON.stringify(response)); + if (response.success) { + window.location.href = window.location.pathname + window.location.search; + } else { + alert((lang.errorDeletingSection || 'Fehler') + ': ' + (response.error || 'Unbekannter Fehler')); + } + }, 'json').fail(function(xhr, status, error) { + debugLog('AJAX Fehler: ' + status + ' ' + error); + alert((lang.errorDeletingSection || 'Fehler beim Löschen') + ': ' + error); + }); +} +/** + * Fügt leere Sections an die richtige Stelle in der Tabelle ein + */ +function insertEmptySections() { + var docInfo = getDocumentInfo(); + if (!docInfo.id) return; + + debugLog('📦 Lade alle Sections für ' + docInfo.type + ' ID ' + docInfo.id); + + $.get('/dolibarr/custom/subtotaltitle/ajax/get_sections.php', { + facture_id: docInfo.id, + document_type: docInfo.type + }, function(response) { + if (!response.success) return; + + debugLog('Gefundene Sections: ' + response.sections.length); + + // Filtere nur leere Sections + var emptySections = response.sections.filter(function(s) { return s.is_empty; }); + + debugLog('Leere Sections: ' + emptySections.length); + + // Füge jede leere Section an die richtige Stelle ein + emptySections.forEach(function(section) { + insertEmptySection(section); + }); + + }, 'json'); +} +/** + * Fügt eine leere Section an die richtige Stelle ein + */ +function insertEmptySection(section) { + if ($('tr.section-header[data-section-id="' + section.id + '"]').length > 0) { + debugLog('Section ' + section.title + ' existiert bereits, überspringe'); + return; + } + + debugLog('Füge leere Section ein: ' + section.title + ' (order: ' + section.line_order + ')'); + + var sectionHtml = ''; + + // Titel (colspan=10) + sectionHtml += ''; + sectionHtml += ''; + sectionHtml += section.title; + sectionHtml += '0 Produkte'; + + // Buttons + sectionHtml += ''; + sectionHtml += ''; + sectionHtml += ''; + sectionHtml += ''; + + // Edit (Spalte 11) + sectionHtml += ''; + sectionHtml += ''; + sectionHtml += ''; + sectionHtml += ''; + + // Delete (Spalte 12) - leere Section = normaler Mülleimer + sectionHtml += ''; + sectionHtml += ''; + sectionHtml += ''; + sectionHtml += ''; + + // Move (Spalte 13) + sectionHtml += ''; + + // Unlink (Spalte 14) + sectionHtml += ''; + + sectionHtml += ''; + + // Finde die richtige Position + var inserted = false; + $('#tablelines tbody tr').each(function() { + var $row = $(this); + + if ($row.hasClass('section-empty')) return; + + var rowOrder = parseInt($row.attr('data-line-order')); + if (rowOrder && section.line_order < rowOrder) { + $row.before(sectionHtml); + inserted = true; + debugLog(' → Eingefügt vor Zeile mit order ' + rowOrder); + return false; + } + }); + + if (!inserted) { + // Finde die "Hinzufügen"-Zeile und füge davor ein + var $addRow = $('#tablelines tbody tr.liste_titre_create'); + if ($addRow.length > 0) { + $addRow.before(sectionHtml); + debugLog(' → Vor Hinzufügen-Zeile eingefügt'); + } else { + // Fallback: ans Ende + $('#tablelines tbody').append(sectionHtml); + debugLog(' → Ans Ende angehängt'); + } + } +} +function initDragAndDrop() { + debugLog('🖱️ Installiere Drop-Listener...'); + + setTimeout(function() { + debugLog('✅ Überwache Tabellen-Änderungen...'); + + // Überwache die Tabelle auf Änderungen + var observer = new MutationObserver(function(mutations) { + mutations.forEach(function(mutation) { + // Prüfe ob Zeilen verschoben wurden + if (mutation.type === 'childList') { + // Ignoriere wenn gerade gezogen wird + if (isDragging) { + debugLog('📦 Tabelle geändert (Drag läuft noch...)'); + return; + } + + debugLog('📦 Tabelle geändert - prüfe Reihenfolge...'); + + // Warte kurz, dann speichere unsere Section-Logik + setTimeout(function() { + if (!isDragging) { + saveCurrentOrder(); + } + }, 500); + } + }); + }); + + // Beobachte tbody + var tbody = document.querySelector('#tablelines tbody'); + if (tbody) { + observer.observe(tbody, { + childList: true, + subtree: false + }); + debugLog('✅ MutationObserver installiert!'); + } + + }, 2000); +} + +function saveCurrentOrder() { + // Nicht speichern wenn gerade Subtotal getoggelt wird + if (isTogglingSubtotal) { + debugLog('⏸️ Skip saveCurrentOrder - Subtotal Toggle aktiv'); + return; + } + + debugLog('💾 Speichere Section-Zuordnungen...'); + + var updates = []; + var order = 1; + var currentSectionId = null; + + $('#tablelines tbody tr').each(function() { + var $row = $(this); + + if ($row.hasClass('liste_titre') || + $row.hasClass('liste_titre_add') || + $row.hasClass('liste_titre_create') || + $row.hasClass('trlinefordates') || + $row.attr('id') === 'trlinefordates') { + return; + } + + if ($row.hasClass('section-header')) { + var sectionId = $row.attr('data-section-id'); + if (sectionId) { + currentSectionId = sectionId; + updates.push({ + type: 'section', + id: sectionId, + order: order + }); + debugLog(' ' + order + '. 📁 Section #' + sectionId); + order++; + } + } + else if ($row.attr('id') && $row.attr('id').indexOf('row-') === 0) { + var productId = $row.attr('id').replace('row-', ''); + + updates.push({ + type: 'product', + id: productId, + order: order, + parent_section: currentSectionId + }); + debugLog(' ' + order + '. 📦 Produkt #' + productId + ' → ' + (currentSectionId ? 'Section ' + currentSectionId : 'FREI')); + order++; + } + else if ($row.hasClass('textline-row')) { + var textlineId = $row.attr('data-textline-id'); + if (textlineId) { + updates.push({ + type: 'text', + id: textlineId, + order: order, + parent_section: currentSectionId + }); + debugLog(' ' + order + '. 📝 Text #' + textlineId + ' → ' + (currentSectionId ? 'Section ' + currentSectionId : 'FREI')); + order++; + } + } + }); + + if (updates.length === 0) { + debugLog('⚠️ Keine Updates gefunden!'); + return; + } + + debugLog('🚀 Sende ' + updates.length + ' Updates...'); + + var docInfo = getDocumentInfo(); + $.post('/dolibarr/custom/subtotaltitle/ajax/reorder_all.php', { + facture_id: getFactureId(), + document_type: docInfo.type, + new_order: JSON.stringify(updates) + }, function(response) { + debugLog('✅ Server: ' + JSON.stringify(response)); + // Kein Reload mehr - Reihenfolge ist gespeichert + // if (response.success) { + // debugLog('🔄 Reload...'); + // window.location.href = window.location.pathname + window.location.search; + // } + }, 'json').fail(function(xhr) { + debugLog('❌ Fehler: ' + xhr.responseText); + }); +} + +/** + * Verschiebt ein Produkt zu einer Section + * MUSS AUSSERHALB sein! + */ +function moveProductToSection(productId, sectionId, newLineOrder) { + var lang = (typeof subtotalTitleLang !== 'undefined') ? subtotalTitleLang : {}; + debugLog('🚀 Verschiebe Produkt ' + productId + ' zu Section ' + (sectionId || 'FREI') + ' auf Position ' + newLineOrder); + + $.post('/dolibarr/custom/subtotaltitle/ajax/move_product.php', { + product_id: productId, + new_section_id: sectionId || 0, + new_line_order: newLineOrder + }, function(response) { + debugLog('Move response: ' + JSON.stringify(response)); + if (response.success) { + window.location.href = window.location.pathname + window.location.search; + } else { + alert((lang.errorReordering || 'Fehler') + ': ' + (response.error || 'Unbekannter Fehler')); + } + }, 'json').fail(function(xhr, status, error) { + if (SUBTOTAL_DEBUG) { + console.error('AJAX Fehler: ' + status); + console.error('Response Text: ' + xhr.responseText); + } + alert((lang.errorReordering || 'Fehler beim Verschieben') + (SUBTOTAL_DEBUG ? '. Siehe Console (F12) für Details.' : '.')); + }); +} + + +/** + * Fügt allen Zeilen eine "Unlink"-Spalte hinzu + */ +function addUnlinkColumn() { + var $table = $('#tablelines'); + if (!$table.length) return; + + // Prüfe ob schon ausgeführt + if ($table.data('unlink-added')) { + debugLog('🔗 Unlink-Spalte bereits vorhanden, überspringe'); + return; + } + $table.data('unlink-added', true); + + debugLog('🔗 Füge Unlink-Spalte hinzu...'); + + // THEAD: Leere Spalte hinzufügen + $table.find('thead tr').each(function() { + if ($(this).find('th.linecolunlink').length === 0) { + $(this).append(''); + } + }); + + // Alle Body-Zeilen durchgehen + $table.find('tbody tr').each(function() { + var $row = $(this); + var rowId = $row.attr('id') || ''; + + // Prüfe ob Zeile bereits eine Unlink-Spalte hat + if ($row.find('td.linecolunlink').length > 0) { + return; + } + + // Produkt-Zeilen (row-XXX) + if (rowId.match(/^row-\d+/)) { + var lineId = rowId.replace('row-', '').split('-')[0]; + var parentSection = $row.attr('data-parent-section'); + + if (parentSection && parentSection !== 'null') { + // Hat Section → Unlink-Button + $row.append(''); + } else { + // Keine Section → leere Zelle + $row.append(''); + } + return; + } + + // Alle anderen Zeilen (trlinefordates, liste_titre, etc.): leere Zelle + if ($row.find('td').length > 0) { + $row.append(''); + } + }); + + debugLog('✅ Unlink-Spalte hinzugefügt'); +} +/** + * PLAN B: Section-Assignment beim Produkt hinzufügen + */ +$(document).ready(function() { + + // Unlink-Spalte hinzufügen (nach data-Attributen) + setTimeout(addUnlinkColumn, 600); + + // 1. Beim Submit des Formulars: Section merken + $(document).on('submit', 'form[name="addproduct"]', function(e) { + var selectedSection = $('#section_id_dropdown').val(); + + if (selectedSection) { + debugLog('📝 Merke Section ' + selectedSection + ' für nächstes Produkt'); + sessionStorage.setItem('pending_section_assignment', selectedSection); + sessionStorage.setItem('pending_section_facture', getFactureId()); + } + }); + + // 2. Nach Page Reload: Prüfe ob Assignment pending ist + var pendingSection = sessionStorage.getItem('pending_section_assignment'); + var pendingFacture = sessionStorage.getItem('pending_section_facture'); + var currentFacture = getFactureId(); + + if (pendingSection && pendingFacture == currentFacture) { + debugLog('✅ Section-Assignment pending: Section ' + pendingSection); + + // Entferne aus sessionStorage + sessionStorage.removeItem('pending_section_assignment'); + sessionStorage.removeItem('pending_section_facture'); + + // Warte kurz, dann weise zu + setTimeout(function() { + assignLastProductToSection(pendingSection, currentFacture); + }, 1000); + } +}); + +/** + * Weist das neueste Produkt einer Section zu + */ +function assignLastProductToSection(sectionId, factureId) { + var docInfo = getDocumentInfo(); + debugLog('🎯 Weise neustes Produkt zu Section ' + sectionId + ' zu (docType: ' + docInfo.type + ')...'); + + $.post('/dolibarr/custom/subtotaltitle/ajax/assign_last_product.php', { + facture_id: factureId, + section_id: sectionId, + document_type: docInfo.type + }, function(response) { + debugLog('✅ Assignment Response: ' + JSON.stringify(response)); + if (response.success) { + debugLog('✅ Produkt #' + response.product_id + ' zu Section zugewiesen'); + window.location.href = window.location.pathname + window.location.search; + } else { + debugLog('❌ Fehler: ' + response.error); + } + }, 'json').fail(function(xhr, status, error) { + debugLog('❌ AJAX Fehler: ' + status); + debugLog('Response: ' + xhr.responseText); + }); +} + +/** + * Ein-/Ausklappen einer Section + */ +function toggleSection(sectionId) { + var $sectionRow = $('tr.section-header[data-section-id="' + sectionId + '"]'); + var $toggle = $sectionRow.find('.section-toggle'); + var isCollapsed = $sectionRow.hasClass('collapsed'); + + if (isCollapsed) { + // Ausklappen + $sectionRow.removeClass('collapsed'); + $('tr[data-parent-section="' + sectionId + '"]').removeClass('section-collapsed'); + $toggle.text('▼'); + saveCollapseState(sectionId, false); + debugLog('📂 Section ' + sectionId + ' ausgeklappt'); + } else { + // Einklappen + $sectionRow.addClass('collapsed'); + $('tr[data-parent-section="' + sectionId + '"]').addClass('section-collapsed'); + $toggle.text('▶'); + saveCollapseState(sectionId, true); + debugLog('📁 Section ' + sectionId + ' eingeklappt'); + } +} + +/** + * Alle Sections einklappen + */ +function collapseAllSections() { + $('tr.section-header').each(function() { + var sectionId = $(this).attr('data-section-id'); + $(this).addClass('collapsed'); + $('tr[data-parent-section="' + sectionId + '"]').addClass('section-collapsed'); + saveCollapseState(sectionId, true); + }); + debugLog('📁 Alle Sections eingeklappt'); +} + +/** + * Alle Sections ausklappen + */ +function expandAllSections() { + $('tr.section-header').each(function() { + var sectionId = $(this).attr('data-section-id'); + $(this).removeClass('collapsed'); + $('tr[data-parent-section="' + sectionId + '"]').removeClass('section-collapsed'); + saveCollapseState(sectionId, false); + }); + debugLog('📂 Alle Sections ausgeklappt'); +} + +/** + * Speichert Collapse-Zustand in localStorage + */ +function saveCollapseState(sectionId, isCollapsed) { + var key = 'section_collapsed_' + sectionId; + if (isCollapsed) { + localStorage.setItem(key, '1'); + debugLog('💾 Gespeichert: ' + key + ' = 1'); + } else { + localStorage.removeItem(key); + debugLog('💾 Gelöscht: ' + key); + } +} + +/** + * Lädt Collapse-Zustand aus localStorage + */ +function loadCollapseState(sectionId) { + var key = 'section_collapsed_' + sectionId; + var value = localStorage.getItem(key); + debugLog('📂 Lade: ' + key + ' = ' + value); + return value === '1'; +} + +function initCollapse() { + debugLog('🔽 Initialisiere Collapse...'); + + // Aktualisiere Count und lade Zustand für jede Section + $('tr.section-header').each(function() { + var sectionId = $(this).attr('data-section-id'); + var productCount = $('tr[data-parent-section="' + sectionId + '"]').length; + + // Update count + $(this).find('.section-count').text(productCount + ' Produkte'); + + // Lade gespeicherten Zustand + var isCollapsed = loadCollapseState(sectionId); + if (isCollapsed) { + $(this).addClass('collapsed'); + $(this).find('.section-toggle').text('▶'); + $('tr[data-parent-section="' + sectionId + '"]').addClass('section-collapsed'); + debugLog('Section ' + sectionId + ': ' + productCount + ' Produkte (eingeklappt)'); + } else { + debugLog('Section ' + sectionId + ': ' + productCount + ' Produkte'); + } + }); + + colorSections(); + + // NEU: Fehlende Subtotals einfügen + insertMissingSubtotals(); + + debugLog('✅ Collapse initialisiert'); +} +/** + * Fügt fehlende Subtotals am Ende ein (für letzte Section) + */ +function insertMissingSubtotals() { + debugLog('🔢 Prüfe fehlende Subtotals...'); + + $('tr.section-header').each(function() { + var $header = $(this); + var sectionId = $header.attr('data-section-id'); + var $checkbox = $header.find('.subtotal-toggle'); + + // Nur wenn Checkbox aktiviert ist + if (!$checkbox.length || !$checkbox.is(':checked')) { + return; + } + + // Finde letztes Produkt dieser Section + var $products = $('tr[data-parent-section="' + sectionId + '"]'); + if ($products.length === 0) { + return; + } + + var $lastProduct = $products.last(); + + // Prüfe ob direkt danach schon ein Subtotal kommt + var $nextRow = $lastProduct.next('tr'); + if ($nextRow.hasClass('subtotal-row')) { + debugLog(' Section ' + sectionId + ': Subtotal vorhanden ✓'); + return; + } + + debugLog(' Section ' + sectionId + ': Subtotal fehlt - füge ein...'); + + // Berechne Summe aus Produktzeilen + var sum = 0; + $products.each(function() { + var $row = $(this); + // Versuche Netto-Betrag aus der Zeile zu holen + var priceText = $row.find('td.linecolht').text().trim(); + if (!priceText) { + // Fallback: letzte Spalte mit Zahl + $row.find('td').each(function() { + var text = $(this).text().trim(); + if (text.match(/^-?[\d\s.,]+$/)) { + priceText = text; + } + }); + } + if (priceText) { + var price = parseFloat(priceText.replace(/\s/g, '').replace('.', '').replace(',', '.')); + if (!isNaN(price)) { + sum += price; + } + } + }); + + var formattedSum = sum.toLocaleString('de-DE', { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + }); + + // Subtotal-Zeile einfügen + var html = ''; + html += 'Zwischensumme:'; + html += '' + formattedSum + ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + + $lastProduct.after(html); + debugLog(' → Subtotal eingefügt: ' + formattedSum + ' €'); + }); +} + +$(document).ready(function() { + setTimeout(function() { + insertEmptySections(); // ← HIER HINZUFÜGEN! + insertTextLines(); + initCollapse(); + }, 1500); +}); + +/** + * Fügt Textzeilen an die richtige Stelle ein + */ +function insertTextLines() { + var docInfo = getDocumentInfo(); + if (!docInfo.id) return; + + debugLog('📝 Lade Textzeilen für ' + docInfo.type + ' ID ' + docInfo.id); + + $.get('/dolibarr/custom/subtotaltitle/ajax/get_textlines.php', { + facture_id: docInfo.id, + document_type: docInfo.type + }, function(response) { + if (!response.success) return; + + debugLog('Gefundene Textzeilen: ' + response.textlines.length); + + response.textlines.forEach(function(textline) { + insertTextLine(textline); + }); + + // tableDnD neu initialisieren damit Textzeilen ziehbar sind + if (response.textlines.length > 0) { + reinitTableDnD(); + } + + }, 'json'); +} + +function toggleSubtotal(sectionId, checkbox) { + event.stopPropagation(); + + var show = checkbox.checked; + + debugLog('🔢 Toggle Subtotal für Section ' + sectionId + ': ' + show); + + $.ajax({ + url: '/dolibarr/custom/subtotaltitle/ajax/toggle_subtotal.php', + method: 'POST', + data: { + section_id: sectionId, + show: show ? 1 : 0 + }, + dataType: 'json', + success: function(response) { + debugLog('Subtotal Response: ' + JSON.stringify(response)); + if (response.success && response.reload) { + window.location.reload(); + } + }, + error: function(xhr, status, error) { + debugLog('Subtotal AJAX Error: ' + error); + } + }); +} + +/** + * Initialisiert tableDnD neu für dynamisch hinzugefügte Zeilen + */ +function reinitTableDnD() { + debugLog('🔄 Reinitialisiere tableDnD...'); + + var $table = $('#tablelines'); + if ($table.length && typeof $.fn.tableDnD !== 'undefined') { + + // Neu initialisieren + $table.tableDnD({ + onDragClass: 'myDragClass', + dragHandle: '.linecolmove', + onDragStart: function(table, row) { + isDragging = true; + debugLog('🎯 Drag gestartet: ' + row.className); + }, + onDrop: function(table, row) { + isDragging = false; + debugLog('📦 Drop: ' + row.className); + + // Kurz warten, dann speichern + setTimeout(function() { + saveCurrentOrder(); + }, 300); + } + }); + + debugLog('✅ tableDnD neu initialisiert'); + } +} + +/** + * Fügt eine Textzeile an die richtige Stelle ein + */ +function insertTextLine(textline) { + if ($('tr.textline-row[data-textline-id="' + textline.id + '"]').length > 0) { + debugLog('Textzeile ' + textline.id + ' existiert bereits, überspringe'); + return; + } + + debugLog('Füge Textzeile ein: ' + textline.title + ' (order: ' + textline.line_order + ')'); + + var html = ''; + + // Inhalt (colspan=10) + html += ''; + html += textline.title; + html += ''; + + // Edit (Spalte 11) + html += ''; + html += ''; + html += ''; + html += ''; + + // Delete (Spalte 12) + html += ''; + html += ''; + html += ''; + html += ''; + + // Move (Spalte 13) + html += ''; + + // Unlink (Spalte 14) + html += ''; + + html += ''; + + // Finde die richtige Position + var inserted = false; + $('#tablelines tbody tr').each(function() { + var $row = $(this); + var rowOrder = parseInt($row.attr('data-line-order')); + + if (rowOrder && textline.line_order < rowOrder) { + $row.before(html); + inserted = true; + debugLog(' → Eingefügt vor Zeile mit order ' + rowOrder); + return false; + } + }); + + if (!inserted) { + var $addRow = $('#tablelines tbody tr.liste_titre_create'); + if ($addRow.length > 0) { + $addRow.before(html); + debugLog(' → Vor Hinzufügen-Zeile eingefügt'); + } else { + $('#tablelines tbody').append(html); + debugLog(' → Ans Ende angehängt'); + } + } +} + +/** + * Erstellt eine neue Textzeile + */ +function createTextLine() { + var lang = (typeof subtotalTitleLang !== 'undefined') ? subtotalTitleLang : {}; + var text = prompt(lang.textlineContent || 'Text eingeben:'); + if (!text) return; + + var docInfo = getDocumentInfo(); + if (!docInfo.id) { + alert(lang.errorLoadingSections || 'Fehler: Keine Dokument-ID gefunden'); + return; + } + + debugLog('Erstelle Textzeile für ' + docInfo.type + ' ID ' + docInfo.id); + + $.post('/dolibarr/custom/subtotaltitle/ajax/create_textline.php', { + facture_id: docInfo.id, + document_type: docInfo.type, + text: text + }, function(response) { + debugLog('Textzeile erstellt: ' + JSON.stringify(response)); + if (response.success) { + window.location.href = window.location.pathname + window.location.search; + } else { + alert((lang.errorSavingTextline || 'Fehler') + ': ' + (response.error || 'Unbekannter Fehler')); + } + }, 'json').fail(function(xhr, status, error) { + debugLog('AJAX Fehler: ' + status + ' ' + error); + alert((lang.errorSavingTextline || 'Fehler beim Erstellen') + ': ' + error); + }); +} + +/** + * Textzeile bearbeiten + */ +function editTextLine(textlineId, currentText) { + var lang = (typeof subtotalTitleLang !== 'undefined') ? subtotalTitleLang : {}; + var newText = prompt(lang.textlineContent || 'Text bearbeiten:', currentText || ''); + if (!newText) return; + + debugLog('✏️ Bearbeite Textzeile ' + textlineId); + + $.post('/dolibarr/custom/subtotaltitle/ajax/edit_textline.php', { + textline_id: textlineId, + text: newText + }, function(response) { + debugLog('Edit response: ' + JSON.stringify(response)); + if (response.success) { + window.location.href = window.location.pathname + window.location.search; + } else { + alert((lang.errorSavingTextline || 'Fehler') + ': ' + (response.error || 'Unbekannter Fehler')); + } + }, 'json').fail(function(xhr, status, error) { + debugLog('AJAX Fehler: ' + status + ' ' + error); + alert((lang.errorSavingTextline || 'Fehler beim Bearbeiten') + ': ' + error); + }); +} + +/** + * Textzeile löschen + */ +function deleteTextLine(textlineId) { + var lang = (typeof subtotalTitleLang !== 'undefined') ? subtotalTitleLang : {}; + if (!confirm(lang.confirmDeleteTextline || 'Textzeile wirklich löschen?')) { + return; + } + + debugLog('🗑️ Lösche Textzeile ' + textlineId); + + $.post('/dolibarr/custom/subtotaltitle/ajax/delete_textline.php', { + textline_id: textlineId + }, function(response) { + debugLog('Delete response: ' + JSON.stringify(response)); + if (response.success) { + window.location.href = window.location.pathname + window.location.search; + } else { + alert((lang.errorDeletingTextline || 'Fehler') + ': ' + (response.error || 'Unbekannter Fehler')); + } + }, 'json').fail(function(xhr, status, error) { + debugLog('AJAX Fehler: ' + status + ' ' + error); + alert((lang.errorDeletingTextline || 'Fehler beim Löschen') + ': ' + error); + }); +} + +/** + * Entfernt ein Produkt aus seiner Section + */ +function removeFromSection(productId) { + var lang = (typeof subtotalTitleLang !== 'undefined') ? subtotalTitleLang : {}; + if (!confirm(lang.confirmRemoveFromSection || 'Produkt aus Positionsgruppe entfernen?')) { + return; + } + + debugLog('🔓 Entferne Produkt ' + productId + ' aus Section'); + + $.post('/dolibarr/custom/subtotaltitle/ajax/remove_from_section.php', { + product_id: productId + }, function(response) { + debugLog('Remove response: ' + JSON.stringify(response)); + if (response.success) { + window.location.href = window.location.pathname + window.location.search; + } else { + alert((lang.errorReordering || 'Fehler') + ': ' + (response.error || 'Unbekannter Fehler')); + } + }, 'json').fail(function(xhr, status, error) { + debugLog('AJAX Fehler: ' + status + ' ' + error); + alert((lang.errorReordering || 'Fehler') + ': ' + error); + }); +} + +function toggleMassDelete() { + var $checkboxes = $('.mass-delete-checkbox'); + + if ($checkboxes.length === 0) { + // Checkboxen einblenden + $('tr.drag[data-line-order]').each(function() { + var $row = $(this); + var lineId = $row.attr('id')?.match(/row-(\d+)/)?.[1]; + if (lineId) { + $row.find('td:first').prepend( + '' + ); + } + }); + + // Buttons NACH dem Massenlösch-Button einfügen + $('#btnMassDelete').after( + 'Ausgewählte löschen' + + 'Alle auswählen' + + 'Abbrechen' + ); + + // Original-Button verstecken + $('#btnMassDelete').hide(); + + } else { + // Checkboxen + Buttons entfernen + $('.mass-delete-checkbox').remove(); + $('#btnMassDoDelete, #btnMassSelectAll, #btnMassCancel').remove(); + $('#btnMassDelete').show(); + } +} + +function selectAllLines() { + $('.mass-delete-checkbox').prop('checked', true); +} + +function deleteMassSelected() { + var lang = (typeof subtotalTitleLang !== 'undefined') ? subtotalTitleLang : {}; + var selectedIds = []; + + $('.mass-delete-checkbox:checked').each(function() { + selectedIds.push($(this).data('line-id')); + }); + + if (selectedIds.length === 0) { + alert(lang.noLinesSelected || 'Keine Zeilen ausgewählt!'); + return; + } + + var msg1 = (lang.confirmDeleteLines || 'Wirklich %s Zeilen löschen?').replace('%s', selectedIds.length); + if (!confirm(msg1)) { + return; + } + + var msg2 = (lang.confirmDeleteLinesWarning || 'LETZTE WARNUNG: %s Zeilen werden UNWIDERRUFLICH gelöscht!').replace('%s', selectedIds.length); + if (!confirm(msg2)) { + return; + } + + $.post('/dolibarr/custom/subtotaltitle/ajax/mass_delete.php', { + line_ids: JSON.stringify(selectedIds), + facture_id: getFactureId() + }, function(response) { + if (response.success) { + window.location.reload(); + } else { + alert((lang.errorDeletingSection || 'Fehler') + ': ' + (response.error || 'Unbekannt')); + } + }, 'json'); +} + +function getFactureId() { + // Aus URL holen (funktioniert für alle Dokumenttypen) + var match = window.location.search.match(/id=(\d+)/); + return match ? match[1] : 0; +} + +function getDocumentType() { + // Erkenne Dokumenttyp aus URL + var path = window.location.pathname; + if (path.indexOf('/compta/facture/') > -1 || path.indexOf('/facture/') > -1) { + return 'invoice'; + } + if (path.indexOf('/comm/propal/') > -1 || path.indexOf('/propal/') > -1) { + return 'propal'; + } + if (path.indexOf('/commande/') > -1) { + return 'order'; + } + return 'invoice'; // Fallback +} + +/** + * Färbt Sections unterschiedlich ein + */ +function colorSections() { + debugLog('🎨 Färbe Sections ein...'); + + var colors = ['#4a90d9', '#50b87d', '#e67e22', '#9b59b6', '#e74c3c', '#1abc9c', '#f39c12', '#3498db']; + var colorIndex = 0; + + $('tr.section-header').each(function() { + var sectionId = $(this).attr('data-section-id'); + var color = colors[colorIndex % colors.length]; + + // Section-Header färben + $(this).find('td:first').css('border-left', '4px solid ' + color); + + // Alle Produkte dieser Section färben + $('tr[data-parent-section="' + sectionId + '"]').each(function() { + $(this).find('td:first').css('border-left', '4px solid ' + color); + $(this).find('td').css('background-color', hexToRgba(color, 0.05)); + }); + + // Textzeilen dieser Section färben + $('tr.textline-row[data-parent-section="' + sectionId + '"]').each(function() { + $(this).find('td:first').css('border-left', '4px solid ' + color); + }); + + debugLog(' Section ' + sectionId + ' → ' + color); + colorIndex++; + }); + + debugLog('✅ Sections eingefärbt'); +} + +/** + * Hex zu RGBA konvertieren + */ +function hexToRgba(hex, alpha) { + var r = parseInt(hex.slice(1, 3), 16); + var g = parseInt(hex.slice(3, 5), 16); + var b = parseInt(hex.slice(5, 7), 16); + return 'rgba(' + r + ', ' + g + ', ' + b + ', ' + alpha + ')'; +} diff --git a/js/subtotaltitle_sync.js b/js/subtotaltitle_sync.js new file mode 100755 index 0000000..da3a813 --- /dev/null +++ b/js/subtotaltitle_sync.js @@ -0,0 +1,254 @@ +// ========================================== +// SUBTOTALTITLE SYNC FUNKTIONEN +// Für Synchronisation mit facturedet +// ========================================== + +/** + * Erkenne Dokumenttyp aus URL (lokale Kopie für Sync) + */ +function getDocumentTypeForSync() { + var url = window.location.href; + var docType = 'invoice'; // Default + if (url.indexOf('/comm/propal/') !== -1) { + docType = 'propal'; + } else if (url.indexOf('/commande/') !== -1) { + docType = 'order'; + } + return docType; +} + +/** + * Synchronisiert eine Zeile mit facturedet (hinzufügen) + */ +function syncToFacturedet(lineId, lineType) { + var lang = (typeof subtotalTitleLang !== 'undefined') ? subtotalTitleLang : {}; + debugLog('📤 Sync to facturedet: ' + lineType + ' #' + lineId); + + var docType = getDocumentTypeForSync(); + debugLog('Document type: ' + docType); + + $.post('/dolibarr/custom/subtotaltitle/ajax/sync_to_facturedet.php', { + action: 'add', + line_id: lineId, + line_type: lineType, + document_type: docType + }, function(response) { + debugLog('Sync response: ' + JSON.stringify(response)); + if (response.success) { + updateSyncCheckbox(lineId, true); + debugLog('✅ Zeile zu Rechnung hinzugefügt'); + } else { + alert((lang.errorSyncing || 'Fehler') + ': ' + (response.error || 'Unbekannter Fehler')); + // Checkbox zurücksetzen + $('.sync-checkbox[data-line-id="' + lineId + '"]').prop('checked', false); + } + }, 'json').fail(function(xhr, status, error) { + debugLog('AJAX Fehler: ' + status + ' ' + error); + alert((lang.errorSyncing || 'Fehler beim Synchronisieren') + ': ' + error); + $('.sync-checkbox[data-line-id="' + lineId + '"]').prop('checked', false); + }); +} + +/** + * Entfernt eine Zeile aus facturedet + */ +function removeFromFacturedet(lineId, lineType) { + var lang = (typeof subtotalTitleLang !== 'undefined') ? subtotalTitleLang : {}; + if (!confirm(lang.confirmRemoveLine || 'Zeile aus der Rechnung entfernen?\n\nDie Zeile bleibt in der Positionsgruppen-Verwaltung erhalten.')) { + // Checkbox zurücksetzen + $('.sync-checkbox[data-line-id="' + lineId + '"]').prop('checked', true); + return; + } + + debugLog('📥 Remove from facturedet: ' + lineType + ' #' + lineId); + + var docType = getDocumentTypeForSync(); + debugLog('Document type: ' + docType); + + $.post('/dolibarr/custom/subtotaltitle/ajax/sync_to_facturedet.php', { + action: 'remove', + line_id: lineId, + line_type: lineType, + document_type: docType + }, function(response) { + debugLog('Remove response: ' + JSON.stringify(response)); + if (response.success) { + updateSyncCheckbox(lineId, false); + debugLog('✅ Zeile aus Rechnung entfernt'); + } else { + alert((lang.errorSyncing || 'Fehler') + ': ' + (response.error || 'Unbekannter Fehler')); + $('.sync-checkbox[data-line-id="' + lineId + '"]').prop('checked', true); + } + }, 'json').fail(function(xhr, status, error) { + debugLog('AJAX Fehler: ' + status + ' ' + error); + alert((lang.errorSyncing || 'Fehler') + ': ' + error); + $('.sync-checkbox[data-line-id="' + lineId + '"]').prop('checked', true); + }); +} + +/** + * Toggle-Handler für Sync-Checkbox + */ +function toggleFacturedetSync(lineId, lineType, checkbox) { + if (event) event.stopPropagation(); + + if (checkbox.checked) { + syncToFacturedet(lineId, lineType); + } else { + removeFromFacturedet(lineId, lineType); + } +} + +/** + * Aktualisiert den visuellen Status der Sync-Checkbox + */ +function updateSyncCheckbox(lineId, isInFacturedet) { + var $checkbox = $('.sync-checkbox[data-line-id="' + lineId + '"]'); + $checkbox.prop('checked', isInFacturedet); + + var $row = $checkbox.closest('tr'); + if (isInFacturedet) { + $row.addClass('in-facturedet'); + } else { + $row.removeClass('in-facturedet'); + } +} + +/** + * Synchronisiert ALLE Sections/Textzeilen/Subtotals auf einmal + */ +function syncAllToFacturedet() { + var lang = (typeof subtotalTitleLang !== 'undefined') ? subtotalTitleLang : {}; + if (!confirm(lang.confirmSyncAll || 'Alle Positionsgruppen-Elemente (Sections, Textzeilen, Zwischensummen) zur Rechnung hinzufügen?')) { + return; + } + + debugLog('📤 Sync ALL to facturedet...'); + + var $unchecked = $('.sync-checkbox:not(:checked)'); + var total = $unchecked.length; + var done = 0; + var errors = 0; + + if (total === 0) { + alert(lang.allElementsAlreadyInInvoice || 'Alle Elemente sind bereits in der Rechnung.'); + return; + } + + $unchecked.each(function() { + var lineId = $(this).data('line-id'); + var lineType = $(this).data('line-type'); + + $.post('/dolibarr/custom/subtotaltitle/ajax/sync_to_facturedet.php', { + action: 'add', + line_id: lineId, + line_type: lineType + }, function(response) { + done++; + if (response.success) { + updateSyncCheckbox(lineId, true); + } else { + errors++; + } + if (done >= total) { + debugLog('✅ Sync abgeschlossen: ' + (total - errors) + ' erfolgreich, ' + errors + ' Fehler'); + if (errors > 0) { + var msg = (lang.elementsAddedWithErrors || '%s von %s Elementen hinzugefügt.\n%s Fehler aufgetreten.') + .replace('%s', total - errors).replace('%s', total).replace('%s', errors); + alert(msg); + } else { + var msg = (lang.elementsAddedToInvoice || '%s Elemente zur Rechnung hinzugefügt.').replace('%s', total); + alert(msg); + } + } + }, 'json').fail(function() { + done++; + errors++; + }); + }); +} + +/** + * Entfernt ALLE Sections/Textzeilen/Subtotals aus facturedet + */ +function removeAllFromFacturedet() { + var lang = (typeof subtotalTitleLang !== 'undefined') ? subtotalTitleLang : {}; + if (!confirm(lang.confirmRemoveAll || 'ALLE Positionsgruppen-Elemente aus der Rechnung entfernen?\n\nDie Elemente bleiben in der Verwaltung erhalten.')) { + return; + } + + debugLog('📥 Remove ALL from facturedet...'); + + var $checked = $('.sync-checkbox:checked'); + var total = $checked.length; + var done = 0; + var errors = 0; + + if (total === 0) { + alert(lang.noElementsInInvoice || 'Keine Elemente in der Rechnung vorhanden.'); + return; + } + + $checked.each(function() { + var lineId = $(this).data('line-id'); + var lineType = $(this).data('line-type'); + + $.post('/dolibarr/custom/subtotaltitle/ajax/sync_to_facturedet.php', { + action: 'remove', + line_id: lineId, + line_type: lineType + }, function(response) { + done++; + if (response.success) { + updateSyncCheckbox(lineId, false); + } else { + errors++; + } + if (done >= total) { + debugLog('✅ Remove abgeschlossen: ' + (total - errors) + ' erfolgreich, ' + errors + ' Fehler'); + if (errors > 0) { + var msg = (lang.elementsRemovedWithErrors || '%s von %s Elementen entfernt.\n%s Fehler aufgetreten.') + .replace('%s', total - errors).replace('%s', total).replace('%s', errors); + alert(msg); + } else { + var msg = (lang.elementsRemovedFromInvoice || '%s Elemente aus Rechnung entfernt.').replace('%s', total); + alert(msg); + } + } + }, 'json').fail(function() { + done++; + errors++; + }); + }); +} + +/** + * Aktualisiert alle Subtotals in facturedet (nach Preisänderungen) + */ +function updateAllSubtotals() { + debugLog('🔄 Update all subtotals...'); + + var $subtotals = $('.sync-checkbox[data-line-type="subtotal"]:checked'); + var total = $subtotals.length; + var done = 0; + + if (total === 0) { + debugLog('Keine Subtotals in facturedet'); + return; + } + + $subtotals.each(function() { + var lineId = $(this).data('line-id'); + + $.post('/dolibarr/custom/subtotaltitle/ajax/sync_to_facturedet.php', { + action: 'update_subtotal', + line_id: lineId + }, function(response) { + done++; + debugLog('Subtotal #' + lineId + ' updated: ' + JSON.stringify(response)); + if (done >= total) { + debugLog('✅ Alle ' + total + ' Subtotals aktualisiert'); + } + }, 'json'); + }); +} diff --git a/langs/de_DE/subtotaltitle.lang b/langs/de_DE/subtotaltitle.lang new file mode 100755 index 0000000..4961bf7 --- /dev/null +++ b/langs/de_DE/subtotaltitle.lang @@ -0,0 +1,145 @@ +# Translation file - German + +# +# Generic +# + +# Module label 'ModuleSubtotalTitleName' +ModuleSubtotalTitleName = Positionsgruppen & Zwischensummen +# Module description 'ModuleSubtotalTitleDesc' +ModuleSubtotalTitleDesc = Organisieren Sie Rechnungspositionen in Gruppen mit automatischen Zwischensummen, Textzeilen und Drag & Drop. Perfekt für komplexe, übersichtliche Rechnungen. + +# +# Admin page +# +SubtotalTitleSetup = SubtotalTitle Einstellungen +Settings = Einstellungen +SubtotalTitleSetupPage = Konfigurieren Sie die SubtotalTitle Modul-Einstellungen + +# Settings Sections +GeneralSettings = Grundeinstellungen +FeatureSettings = Funktionen +DisplaySettings = Anzeigeoptionen + +# General Settings +SUBTOTALTITLE_DEBUG_MODE = Debug-Modus +SUBTOTALTITLE_DEBUG_MODE_HELP = Aktiviert Debug-Logging (error_log) zur Fehlersuche. Logs erscheinen im PHP Error-Log. + +SUBTOTALTITLE_DEFAULT_IN_INVOICE = Alle Elemente standardmäßig in Rechnung anzeigen +SUBTOTALTITLE_DEFAULT_IN_INVOICE_HELP = Wenn aktiviert, werden alle Sections, Textzeilen und Zwischensummen automatisch im PDF/Rechnung angezeigt. Wenn deaktiviert, müssen sie manuell über Checkboxen aktiviert werden. + +SUBTOTALTITLE_AUTO_SUBTOTALS = Automatisch Zwischensummen anzeigen +SUBTOTALTITLE_AUTO_SUBTOTALS_HELP = Beim Erstellen einer neuen Section wird die Zwischensumme automatisch aktiviert. + +# Feature Settings +SUBTOTALTITLE_ENABLE_TEXTLINES = Textzeilen aktivieren +SUBTOTALTITLE_ENABLE_TEXTLINES_HELP = Erlaubt das Erstellen von Textzeilen zwischen Rechnungspositionen für zusätzliche Informationen oder Hinweise. + +SUBTOTALTITLE_ENABLE_COLLAPSE = Sections einklappbar machen +SUBTOTALTITLE_ENABLE_COLLAPSE_HELP = Erlaubt das Ein- und Ausklappen von Sections für bessere Übersicht bei großen Rechnungen. + +SUBTOTALTITLE_ENABLE_DRAGDROP = Drag & Drop aktivieren +SUBTOTALTITLE_ENABLE_DRAGDROP_HELP = Erlaubt das Umsortieren von Rechnungspositionen per Drag & Drop. + +# Display Settings +SUBTOTALTITLE_SECTION_BG_COLOR = Section-Header Hintergrundfarbe +SUBTOTALTITLE_SECTION_BG_COLOR_HELP = Hintergrundfarbe für Section-Header (Standard: #f0f0f0) + +SUBTOTALTITLE_SUBTOTAL_BG_COLOR = Zwischensummen Hintergrundfarbe +SUBTOTALTITLE_SUBTOTAL_BG_COLOR_HELP = Hintergrundfarbe für Zwischensummen-Zeilen (Standard: #e8f4e8) + + +# +# About page +# +About = Über +SubtotalTitleAbout = Über SubtotalTitle +SubtotalTitleAboutPage = SubtotalTitle Über-Seite + +# +# Sample page +# +SubtotalTitleArea = SubtotalTitle Bereich +MyPageName = Meine Seite + +# +# Sample widget +# +MyWidget = Mein Widget +MyWidgetDescription = Meine Widget-Beschreibung + +# +# UI Elements - Buttons +# +ButtonCreateTextline = Textzeile erstellen +ButtonToInvoice = → Zur Rechnung +ButtonFromInvoice = ← Aus Rechnung +ButtonExpandAll = Alle ausklappen +ButtonCollapseAll = Alle einklappen +ButtonMassDelete = Zeilen löschen +ButtonDelete = Löschen +ButtonEdit = Bearbeiten +ButtonSave = Speichern +ButtonCancel = Abbrechen + +# +# UI Elements - Section Actions +# +SectionCreate = Produktgruppe erstellen +SectionEdit = Section bearbeiten +SectionDelete = Section löschen +SectionName = Section-Name +SectionSubtotal = Zwischensumme anzeigen +ProductCount = Produkte + +# +# UI Elements - Messages +# +ConfirmMassDelete = Möchten Sie wirklich ALLE Produkte in dieser Rechnung löschen? Dies kann nicht rückgängig gemacht werden! +ConfirmDeleteSection = Möchten Sie diese Section wirklich löschen? +ConfirmDeleteSectionForce = ⚠️ ACHTUNG!\n\nWollen Sie wirklich die Positionsgruppe\nUND alle %s enthaltenen Produkte löschen?\n\nDiese Aktion kann nicht rückgängig gemacht werden! +ConfirmDeleteSectionForce2 = Sind Sie WIRKLICH sicher?\n\n%s Produkte werden unwiderruflich gelöscht! +ConfirmDeleteTextline = Möchten Sie diese Textzeile wirklich löschen? +ConfirmRemoveFromSection = Produkt aus Positionsgruppe entfernen? +ConfirmDeleteLines = Wirklich %s Zeilen löschen? +ConfirmDeleteLinesWarning = LETZTE WARNUNG: %s Zeilen werden UNWIDERRUFLICH gelöscht! +NoLinesSelected = Keine Zeilen ausgewählt! +ErrorLoadingSections = Fehler beim Laden der Sections +ErrorSavingSection = Fehler beim Speichern der Section +ErrorDeletingSection = Fehler beim Löschen der Section +ErrorReordering = Fehler beim Neu-Sortieren +SuccessSectionCreated = Section erfolgreich erstellt +SuccessSectionUpdated = Section erfolgreich aktualisiert +SuccessSectionDeleted = Section erfolgreich gelöscht +SuccessReordered = Reihenfolge erfolgreich gespeichert + +# +# UI Elements - Textlines +# +TextlineCreate = Textzeile erstellen +TextlineEdit = Textzeile bearbeiten +TextlineDelete = Textzeile löschen +TextlineContent = Text +ErrorSavingTextline = Fehler beim Speichern der Textzeile +ErrorDeletingTextline = Fehler beim Löschen der Textzeile +SuccessTextlineCreated = Textzeile erfolgreich erstellt +SuccessTextlineUpdated = Textzeile erfolgreich aktualisiert +SuccessTextlineDeleted = Textzeile erfolgreich gelöscht + +# +# UI Elements - Sync +# +SyncToInvoice = Zur Rechnung synchronisieren +SyncFromInvoice = Von Rechnung entfernen +ConfirmRemoveLine = Zeile aus der Rechnung entfernen?\n\nDie Zeile bleibt in der Positionsgruppen-Verwaltung erhalten. +ConfirmSyncAll = Alle Positionsgruppen-Elemente (Sections, Textzeilen, Zwischensummen) zur Rechnung hinzufügen? +ConfirmRemoveAll = ALLE Positionsgruppen-Elemente aus der Rechnung entfernen?\n\nDie Elemente bleiben in der Verwaltung erhalten. +AllElementsAlreadyInInvoice = Alle Elemente sind bereits in der Rechnung. +NoElementsInInvoice = Keine Elemente in der Rechnung vorhanden. +ElementsAddedToInvoice = %s Elemente zur Rechnung hinzugefügt. +ElementsRemovedFromInvoice = %s Elemente aus Rechnung entfernt. +ElementsAddedWithErrors = %s von %s Elementen hinzugefügt.\n%s Fehler aufgetreten. +ElementsRemovedWithErrors = %s von %s Elementen entfernt.\n%s Fehler aufgetreten. +SuccessSyncedToInvoice = Erfolgreich zur Rechnung synchronisiert +SuccessRemovedFromInvoice = Erfolgreich aus Rechnung entfernt +ErrorSyncing = Fehler beim Synchronisieren diff --git a/langs/en_US/subtotaltitle.lang b/langs/en_US/subtotaltitle.lang new file mode 100755 index 0000000..70cf43b --- /dev/null +++ b/langs/en_US/subtotaltitle.lang @@ -0,0 +1,145 @@ +# Translation file + +# +# Generic +# + +# Module label 'ModuleSubtotalTitleName' +ModuleSubtotalTitleName = Position Groups & Subtotals +# Module description 'ModuleSubtotalTitleDesc' +ModuleSubtotalTitleDesc = Organize invoice lines into groups with automatic subtotals, text lines and drag & drop. Perfect for complex, well-structured invoices. + +# +# Admin page +# +SubtotalTitleSetup = SubtotalTitle setup +Settings = Settings +SubtotalTitleSetupPage = Configure SubtotalTitle module settings + +# Settings Sections +GeneralSettings = General Settings +FeatureSettings = Feature Settings +DisplaySettings = Display Settings + +# General Settings +SUBTOTALTITLE_DEBUG_MODE = Debug Mode +SUBTOTALTITLE_DEBUG_MODE_HELP = Enable debug logging (error_log) for troubleshooting. Logs will appear in PHP error log. + +SUBTOTALTITLE_DEFAULT_IN_INVOICE = Show all elements in invoice by default +SUBTOTALTITLE_DEFAULT_IN_INVOICE_HELP = When enabled, all sections, text lines and subtotals are automatically visible in PDF/invoice. When disabled, you need to manually enable them via checkboxes. + +SUBTOTALTITLE_AUTO_SUBTOTALS = Automatically show subtotals +SUBTOTALTITLE_AUTO_SUBTOTALS_HELP = When creating a new section, automatically enable subtotal display. + +# Feature Settings +SUBTOTALTITLE_ENABLE_TEXTLINES = Enable text lines +SUBTOTALTITLE_ENABLE_TEXTLINES_HELP = Allow creating text lines between invoice positions for additional information or notes. + +SUBTOTALTITLE_ENABLE_COLLAPSE = Enable collapsible sections +SUBTOTALTITLE_ENABLE_COLLAPSE_HELP = Allow sections to be collapsed/expanded for better overview of large invoices. + +SUBTOTALTITLE_ENABLE_DRAGDROP = Enable Drag & Drop +SUBTOTALTITLE_ENABLE_DRAGDROP_HELP = Allow reordering of invoice lines via drag and drop. + +# Display Settings +SUBTOTALTITLE_SECTION_BG_COLOR = Section header background color +SUBTOTALTITLE_SECTION_BG_COLOR_HELP = Background color for section headers (default: #f0f0f0) + +SUBTOTALTITLE_SUBTOTAL_BG_COLOR = Subtotal background color +SUBTOTALTITLE_SUBTOTAL_BG_COLOR_HELP = Background color for subtotal rows (default: #e8f4e8) + + +# +# About page +# +About = About +SubtotalTitleAbout = About SubtotalTitle +SubtotalTitleAboutPage = SubtotalTitle about page + +# +# Sample page +# +SubtotalTitleArea = Home SubtotalTitle +MyPageName = My page name + +# +# Sample widget +# +MyWidget = My widget +MyWidgetDescription = My widget description + +# +# UI Elements - Buttons +# +ButtonCreateTextline = Create text line +ButtonToInvoice = → To Invoice +ButtonFromInvoice = ← From Invoice +ButtonExpandAll = Expand all +ButtonCollapseAll = Collapse all +ButtonMassDelete = Delete lines +ButtonDelete = Delete +ButtonEdit = Edit +ButtonSave = Save +ButtonCancel = Cancel + +# +# UI Elements - Section Actions +# +SectionCreate = Create section +SectionEdit = Edit section +SectionDelete = Delete section +SectionName = Section name +SectionSubtotal = Show subtotal +ProductCount = Products + +# +# UI Elements - Messages +# +ConfirmMassDelete = Do you really want to delete ALL products in this invoice? This cannot be undone! +ConfirmDeleteSection = Do you really want to delete this section? +ConfirmDeleteSectionForce = ⚠️ WARNING!\n\nDo you really want to delete the section\nAND all %s contained products?\n\nThis action cannot be undone! +ConfirmDeleteSectionForce2 = Are you REALLY sure?\n\n%s products will be irrevocably deleted! +ConfirmDeleteTextline = Do you really want to delete this text line? +ConfirmRemoveFromSection = Remove product from section? +ConfirmDeleteLines = Really delete %s lines? +ConfirmDeleteLinesWarning = FINAL WARNING: %s lines will be IRREVOCABLY deleted! +NoLinesSelected = No lines selected! +ErrorLoadingSections = Error loading sections +ErrorSavingSection = Error saving section +ErrorDeletingSection = Error deleting section +ErrorReordering = Error reordering +SuccessSectionCreated = Section successfully created +SuccessSectionUpdated = Section successfully updated +SuccessSectionDeleted = Section successfully deleted +SuccessReordered = Order successfully saved + +# +# UI Elements - Textlines +# +TextlineCreate = Create text line +TextlineEdit = Edit text line +TextlineDelete = Delete text line +TextlineContent = Text +ErrorSavingTextline = Error saving text line +ErrorDeletingTextline = Error deleting text line +SuccessTextlineCreated = Text line successfully created +SuccessTextlineUpdated = Text line successfully updated +SuccessTextlineDeleted = Text line successfully deleted + +# +# UI Elements - Sync +# +SyncToInvoice = Sync to invoice +SyncFromInvoice = Remove from invoice +ConfirmRemoveLine = Remove line from invoice?\n\nThe line will remain in the section management. +ConfirmSyncAll = Add all section elements (sections, text lines, subtotals) to the invoice? +ConfirmRemoveAll = Remove ALL section elements from invoice?\n\nElements will remain in management. +AllElementsAlreadyInInvoice = All elements are already in the invoice. +NoElementsInInvoice = No elements in invoice. +ElementsAddedToInvoice = %s elements added to invoice. +ElementsRemovedFromInvoice = %s elements removed from invoice. +ElementsAddedWithErrors = %s of %s elements added.\n%s errors occurred. +ElementsRemovedWithErrors = %s of %s elements removed.\n%s errors occurred. +SuccessSyncedToInvoice = Successfully synced to invoice +SuccessRemovedFromInvoice = Successfully removed from invoice +ErrorSyncing = Error syncing diff --git a/lib/subtotaltitle.lib.php b/lib/subtotaltitle.lib.php new file mode 100755 index 0000000..a05b154 --- /dev/null +++ b/lib/subtotaltitle.lib.php @@ -0,0 +1,105 @@ + + * + * 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 . + */ + +/** + * \file subtotaltitle/lib/subtotaltitle.lib.php + * \ingroup subtotaltitle + * \brief Library files with common functions for SubtotalTitle + */ + +/** + * Prepare admin pages header + * + * @return array + */ +function subtotaltitleAdminPrepareHead() +{ + global $langs, $conf; + + // global $db; + // $extrafields = new ExtraFields($db); + // $extrafields->fetch_name_optionals_label('myobject'); + + $langs->load("subtotaltitle@subtotaltitle"); + + $h = 0; + $head = array(); + + $head[$h][0] = dol_buildpath("/subtotaltitle/admin/setup.php", 1); + $head[$h][1] = $langs->trans("Settings"); + $head[$h][2] = 'settings'; + $h++; + + /* + $head[$h][0] = dol_buildpath("/subtotaltitle/admin/myobject_extrafields.php", 1); + $head[$h][1] = $langs->trans("ExtraFields"); + $nbExtrafields = (isset($extrafields->attributes['myobject']['label']) && is_countable($extrafields->attributes['myobject']['label'])) ? count($extrafields->attributes['myobject']['label']) : 0; + if ($nbExtrafields > 0) { + $head[$h][1] .= '' . $nbExtrafields . ''; + } + $head[$h][2] = 'myobject_extrafields'; + $h++; + + $head[$h][0] = dol_buildpath("/subtotaltitle/admin/myobjectline_extrafields.php", 1); + $head[$h][1] = $langs->trans("ExtraFieldsLines"); + $nbExtrafields = (isset($extrafields->attributes['myobjectline']['label']) && is_countable($extrafields->attributes['myobjectline']['label'])) ? count($extrafields->attributes['myobject']['label']) : 0; + if ($nbExtrafields > 0) { + $head[$h][1] .= '' . $nbExtrafields . ''; + } + $head[$h][2] = 'myobject_extrafieldsline'; + $h++; + */ + + $head[$h][0] = dol_buildpath("/subtotaltitle/admin/about.php", 1); + $head[$h][1] = $langs->trans("About"); + $head[$h][2] = 'about'; + $h++; + + // Show more tabs from modules + // Entries must be declared in modules descriptor with line + //$this->tabs = array( + // 'entity:+tabname:Title:@subtotaltitle:/subtotaltitle/mypage.php?id=__ID__' + //); // to add new tab + //$this->tabs = array( + // 'entity:-tabname:Title:@subtotaltitle:/subtotaltitle/mypage.php?id=__ID__' + //); // to remove a tab + complete_head_from_modules($conf, $langs, null, $head, $h, 'subtotaltitle@subtotaltitle'); + + complete_head_from_modules($conf, $langs, null, $head, $h, 'subtotaltitle@subtotaltitle', 'remove'); + + return $head; +} + +/** + * Debug-Log-Funktion die nur bei aktiviertem Debug-Modus schreibt + * + * @param string $message Die Log-Nachricht + * @param bool $force Erzwingt Logging auch wenn Debug-Modus aus ist + * @return void + */ +function subtotaltitle_debug_log($message, $force = false) +{ + global $conf; + + // Debug-Modus aus Config laden + $debug_mode = getDolGlobalInt('SUBTOTALTITLE_DEBUG_MODE', 0); + + // Nur loggen wenn Debug-Modus an ist oder force=true + if ($debug_mode || $force) { + error_log('[SubtotalTitle] ' . $message); + } +} diff --git a/modulebuilder.txt b/modulebuilder.txt new file mode 100755 index 0000000..670a177 --- /dev/null +++ b/modulebuilder.txt @@ -0,0 +1,3 @@ +# DO NOT DELETE THIS FILE MANUALLY +# File to flag module built using official module template. +# When this file is present into a module directory, you can edit it with the module builder tool. \ No newline at end of file diff --git a/sql/dolibarr_allversions.sql b/sql/dolibarr_allversions.sql new file mode 100755 index 0000000..5026bb4 --- /dev/null +++ b/sql/dolibarr_allversions.sql @@ -0,0 +1,3 @@ +-- +-- Script run when an upgrade of Dolibarr is done. Whatever is the Dolibarr version. +-- diff --git a/sql/fix_foreign_keys.sql b/sql/fix_foreign_keys.sql new file mode 100644 index 0000000..cf6c3b2 --- /dev/null +++ b/sql/fix_foreign_keys.sql @@ -0,0 +1,30 @@ +-- Fix Foreign Key Constraints für Multi-Document-Support +-- Diese Felder sollten NULL sein können, da eine Section entweder zu einer +-- Rechnung ODER einem Angebot ODER einem Auftrag gehört, aber nie zu allen gleichzeitig. + +-- 1. Foreign Key Constraints temporär entfernen +ALTER TABLE llx_facture_lines_manager DROP FOREIGN KEY IF EXISTS `1`; +ALTER TABLE llx_facture_lines_manager DROP FOREIGN KEY IF EXISTS `2`; +ALTER TABLE llx_facture_lines_manager DROP FOREIGN KEY IF EXISTS `3`; +ALTER TABLE llx_facture_lines_manager DROP FOREIGN KEY IF EXISTS `fk_facture_lines_manager_facture`; +ALTER TABLE llx_facture_lines_manager DROP FOREIGN KEY IF EXISTS `fk_facture_lines_manager_propal`; +ALTER TABLE llx_facture_lines_manager DROP FOREIGN KEY IF EXISTS `fk_facture_lines_manager_commande`; + +-- 2. Felder auf NULL ändern +ALTER TABLE llx_facture_lines_manager + MODIFY COLUMN fk_facture INT NULL DEFAULT NULL, + MODIFY COLUMN fk_propal INT NULL DEFAULT NULL, + MODIFY COLUMN fk_commande INT NULL DEFAULT NULL; + +-- 3. Foreign Key Constraints wieder hinzufügen (mit ON DELETE CASCADE) +ALTER TABLE llx_facture_lines_manager + ADD CONSTRAINT fk_facture_lines_manager_facture + FOREIGN KEY (fk_facture) REFERENCES llx_facture(rowid) ON DELETE CASCADE; + +ALTER TABLE llx_facture_lines_manager + ADD CONSTRAINT fk_facture_lines_manager_propal + FOREIGN KEY (fk_propal) REFERENCES llx_propal(rowid) ON DELETE CASCADE; + +ALTER TABLE llx_facture_lines_manager + ADD CONSTRAINT fk_facture_lines_manager_commande + FOREIGN KEY (fk_commande) REFERENCES llx_commande(rowid) ON DELETE CASCADE; diff --git a/sql/llx_facture_lines_manager.key.sql b/sql/llx_facture_lines_manager.key.sql new file mode 100644 index 0000000..7faa571 --- /dev/null +++ b/sql/llx_facture_lines_manager.key.sql @@ -0,0 +1,60 @@ +-- Copyright (C) 2026 Eduard Wisch +-- +-- 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 . + +-- +-- Tabelle für Verwaltung von Rechnungs-, Angebots- und Auftragszeilen +-- + +CREATE TABLE IF NOT EXISTS llx_facture_lines_manager ( + rowid INT(11) NOT NULL AUTO_INCREMENT PRIMARY KEY, + + -- Dokumenttyp und Referenzen + document_type VARCHAR(20) DEFAULT 'invoice' NOT NULL, + fk_facture INT(11) DEFAULT NULL, + fk_propal INT(11) DEFAULT NULL, + fk_commande INT(11) DEFAULT NULL, + + -- Zeilentyp: 'section', 'text', 'subtotal', 'product' + line_type VARCHAR(20) NOT NULL, + + -- Referenzen auf Detailzeilen + fk_facturedet INT(11) DEFAULT NULL, + fk_propaldet INT(11) DEFAULT NULL, + fk_commandedet INT(11) DEFAULT NULL, + + -- Section-Informationen + parent_section INT(11) DEFAULT NULL, + title VARCHAR(255) DEFAULT NULL, + line_order INT(11) DEFAULT 0, + show_subtotal TINYINT(1) DEFAULT 0, + collapsed TINYINT(1) DEFAULT 0, + in_facturedet TINYINT(1) DEFAULT 0, + + -- Timestamps + date_creation DATETIME NOT NULL, + tms TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + -- Indizes + INDEX idx_fk_facture (fk_facture), + INDEX idx_fk_propal (fk_propal), + INDEX idx_fk_commande (fk_commande), + INDEX idx_fk_facturedet (fk_facturedet), + INDEX idx_fk_propaldet (fk_propaldet), + INDEX idx_fk_commandedet (fk_commandedet), + INDEX idx_document_type (document_type), + INDEX idx_line_type (line_type), + INDEX idx_parent_section (parent_section), + INDEX idx_line_order (line_order) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/sql/llx_facture_lines_manager.sql b/sql/llx_facture_lines_manager.sql new file mode 100644 index 0000000..98fd7f7 --- /dev/null +++ b/sql/llx_facture_lines_manager.sql @@ -0,0 +1,136 @@ +-- Copyright (C) 2026 Eduard Wisch +-- +-- Haupttabelle für Verwaltung von Rechnungs-, Angebots- und Auftragszeilen +-- Diese Datei wird beim Modul-Upgrade ausgeführt +-- + +-- Prüfen ob Spalten existieren und hinzufügen falls nicht +SET @exist := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_schema = DATABASE() + AND table_name = 'llx_facture_lines_manager' + AND column_name = 'document_type'); + +SET @sqlstmt := IF(@exist = 0, + 'ALTER TABLE llx_facture_lines_manager ADD COLUMN document_type VARCHAR(20) DEFAULT ''invoice'' NOT NULL AFTER fk_facture', + 'SELECT ''Column document_type already exists'' as msg'); +PREPARE stmt FROM @sqlstmt; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- fk_propal hinzufügen +SET @exist := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_schema = DATABASE() + AND table_name = 'llx_facture_lines_manager' + AND column_name = 'fk_propal'); + +SET @sqlstmt := IF(@exist = 0, + 'ALTER TABLE llx_facture_lines_manager ADD COLUMN fk_propal INT(11) DEFAULT NULL AFTER document_type', + 'SELECT ''Column fk_propal already exists'' as msg'); +PREPARE stmt FROM @sqlstmt; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- fk_commande hinzufügen +SET @exist := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_schema = DATABASE() + AND table_name = 'llx_facture_lines_manager' + AND column_name = 'fk_commande'); + +SET @sqlstmt := IF(@exist = 0, + 'ALTER TABLE llx_facture_lines_manager ADD COLUMN fk_commande INT(11) DEFAULT NULL AFTER fk_propal', + 'SELECT ''Column fk_commande already exists'' as msg'); +PREPARE stmt FROM @sqlstmt; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- fk_propaldet hinzufügen +SET @exist := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_schema = DATABASE() + AND table_name = 'llx_facture_lines_manager' + AND column_name = 'fk_propaldet'); + +SET @sqlstmt := IF(@exist = 0, + 'ALTER TABLE llx_facture_lines_manager ADD COLUMN fk_propaldet INT(11) DEFAULT NULL AFTER fk_facturedet', + 'SELECT ''Column fk_propaldet already exists'' as msg'); +PREPARE stmt FROM @sqlstmt; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- fk_commandedet hinzufügen +SET @exist := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_schema = DATABASE() + AND table_name = 'llx_facture_lines_manager' + AND column_name = 'fk_commandedet'); + +SET @sqlstmt := IF(@exist = 0, + 'ALTER TABLE llx_facture_lines_manager ADD COLUMN fk_commandedet INT(11) DEFAULT NULL AFTER fk_propaldet', + 'SELECT ''Column fk_commandedet already exists'' as msg'); +PREPARE stmt FROM @sqlstmt; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- Bestehende Daten aktualisieren +UPDATE llx_facture_lines_manager +SET document_type = 'invoice' +WHERE fk_facture IS NOT NULL AND (document_type IS NULL OR document_type = ''); + +-- Indizes hinzufügen falls sie nicht existieren +SET @exist := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_schema = DATABASE() + AND table_name = 'llx_facture_lines_manager' + AND index_name = 'idx_fk_propal'); + +SET @sqlstmt := IF(@exist = 0, + 'ALTER TABLE llx_facture_lines_manager ADD INDEX idx_fk_propal (fk_propal)', + 'SELECT ''Index idx_fk_propal already exists'' as msg'); +PREPARE stmt FROM @sqlstmt; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @exist := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_schema = DATABASE() + AND table_name = 'llx_facture_lines_manager' + AND index_name = 'idx_fk_commande'); + +SET @sqlstmt := IF(@exist = 0, + 'ALTER TABLE llx_facture_lines_manager ADD INDEX idx_fk_commande (fk_commande)', + 'SELECT ''Index idx_fk_commande already exists'' as msg'); +PREPARE stmt FROM @sqlstmt; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @exist := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_schema = DATABASE() + AND table_name = 'llx_facture_lines_manager' + AND index_name = 'idx_fk_propaldet'); + +SET @sqlstmt := IF(@exist = 0, + 'ALTER TABLE llx_facture_lines_manager ADD INDEX idx_fk_propaldet (fk_propaldet)', + 'SELECT ''Index idx_fk_propaldet already exists'' as msg'); +PREPARE stmt FROM @sqlstmt; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @exist := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_schema = DATABASE() + AND table_name = 'llx_facture_lines_manager' + AND index_name = 'idx_fk_commandedet'); + +SET @sqlstmt := IF(@exist = 0, + 'ALTER TABLE llx_facture_lines_manager ADD INDEX idx_fk_commandedet (fk_commandedet)', + 'SELECT ''Index idx_fk_commandedet already exists'' as msg'); +PREPARE stmt FROM @sqlstmt; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @exist := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_schema = DATABASE() + AND table_name = 'llx_facture_lines_manager' + AND index_name = 'idx_document_type'); + +SET @sqlstmt := IF(@exist = 0, + 'ALTER TABLE llx_facture_lines_manager ADD INDEX idx_document_type (document_type)', + 'SELECT ''Index idx_document_type already exists'' as msg'); +PREPARE stmt FROM @sqlstmt; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/subtotaltitle.kdev4 b/subtotaltitle.kdev4 new file mode 100755 index 0000000..add4938 --- /dev/null +++ b/subtotaltitle.kdev4 @@ -0,0 +1,4 @@ +[Project] +CreatedFrom= +Manager=KDevCustomBuildSystem +Name=subtotaltitle diff --git a/subtotaltitleindex.php b/subtotaltitleindex.php new file mode 100755 index 0000000..5d4b6bc --- /dev/null +++ b/subtotaltitleindex.php @@ -0,0 +1,259 @@ + + * Copyright (C) 2004-2015 Laurent Destailleur + * Copyright (C) 2005-2012 Regis Houssin + * Copyright (C) 2015 Jean-François Ferry + * Copyright (C) 2024 Frédéric France + * Copyright (C) 2026 Eduard Wisch + * + * 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 . + */ + +/** + * \file subtotaltitle/subtotaltitleindex.php + * \ingroup subtotaltitle + * \brief Home page of subtotaltitle top menu + */ + +// Load Dolibarr environment +$res = 0; +// Try main.inc.php into web root known defined into CONTEXT_DOCUMENT_ROOT (not always defined) +if (!$res && !empty($_SERVER["CONTEXT_DOCUMENT_ROOT"])) { + $res = @include $_SERVER["CONTEXT_DOCUMENT_ROOT"]."/main.inc.php"; +} +// Try main.inc.php into web root detected using web root calculated from SCRIPT_FILENAME +$tmp = empty($_SERVER['SCRIPT_FILENAME']) ? '' : $_SERVER['SCRIPT_FILENAME']; +$tmp2 = realpath(__FILE__); +$i = strlen($tmp) - 1; +$j = strlen($tmp2) - 1; +while ($i > 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) { + $i--; + $j--; +} +if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) { + $res = @include substr($tmp, 0, ($i + 1))."/main.inc.php"; +} +if (!$res && $i > 0 && file_exists(dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php")) { + $res = @include dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php"; +} +// Try main.inc.php using relative path +if (!$res && file_exists("../main.inc.php")) { + $res = @include "../main.inc.php"; +} +if (!$res && file_exists("../../main.inc.php")) { + $res = @include "../../main.inc.php"; +} +if (!$res && file_exists("../../../main.inc.php")) { + $res = @include "../../../main.inc.php"; +} +if (!$res) { + die("Include of main fails"); +} + +require_once DOL_DOCUMENT_ROOT.'/core/class/html.formfile.class.php'; + +/** + * @var Conf $conf + * @var DoliDB $db + * @var HookManager $hookmanager + * @var Translate $langs + * @var User $user + */ + +// Load translation files required by the page +$langs->loadLangs(array("subtotaltitle@subtotaltitle")); + +$action = GETPOST('action', 'aZ09'); + +$now = dol_now(); +$max = getDolGlobalInt('MAIN_SIZE_SHORTLIST_LIMIT', 5); + +// Security check - Protection if external user +$socid = GETPOSTINT('socid'); +if (!empty($user->socid) && $user->socid > 0) { + $action = ''; + $socid = $user->socid; +} + +// Initialize a technical object to manage hooks. Note that conf->hooks_modules contains array +//$hookmanager->initHooks(array($object->element.'index')); + +// Security check (enable the most restrictive one) +//if ($user->socid > 0) accessforbidden(); +//if ($user->socid > 0) $socid = $user->socid; +//if (!isModEnabled('subtotaltitle')) { +// accessforbidden('Module not enabled'); +//} +//if (! $user->hasRight('subtotaltitle', 'myobject', 'read')) { +// accessforbidden(); +//} +//restrictedArea($user, 'subtotaltitle', 0, 'subtotaltitle_myobject', 'myobject', '', 'rowid'); +//if (empty($user->admin)) { +// accessforbidden('Must be admin'); +//} + + +/* + * Actions + */ + +// None + + +/* + * View + */ + +$form = new Form($db); +$formfile = new FormFile($db); + +llxHeader("", $langs->trans("SubtotalTitleArea"), '', '', 0, 0, '', '', '', 'mod-subtotaltitle page-index'); + +print load_fiche_titre($langs->trans("SubtotalTitleArea"), '', 'subtotaltitle.png@subtotaltitle'); + +print '
'; + + +/* BEGIN MODULEBUILDER DRAFT MYOBJECT +// Draft MyObject +if (isModEnabled('subtotaltitle') && $user->hasRight('subtotaltitle', 'read')) { + $langs->load("orders"); + + $sql = "SELECT c.rowid, c.ref, c.ref_client, c.total_ht, c.tva as total_tva, c.total_ttc, s.rowid as socid, s.nom as name, s.client, s.canvas"; + $sql.= ", s.code_client"; + $sql.= " FROM ".$db->prefix()."commande as c"; + $sql.= ", ".$db->prefix()."societe as s"; + $sql.= " WHERE c.fk_soc = s.rowid"; + $sql.= " AND c.fk_statut = 0"; + $sql.= " AND c.entity IN (".getEntity('commande').")"; + if ($socid) $sql.= " AND c.fk_soc = ".((int) $socid); + + $resql = $db->query($sql); + if ($resql) + { + $total = 0; + $num = $db->num_rows($resql); + + print ''; + print ''; + print ''; + + $var = true; + if ($num > 0) + { + $i = 0; + while ($i < $num) + { + + $obj = $db->fetch_object($resql); + print ''; + print ''; + print ''; + $i++; + $total += $obj->total_ttc; + } + if ($total>0) + { + + print '"; + } + } + else + { + + print ''; + } + print "
'.$langs->trans("DraftMyObjects").($num?''.$num.'':'').'
'; + + $myobjectstatic->id=$obj->rowid; + $myobjectstatic->ref=$obj->ref; + $myobjectstatic->ref_client=$obj->ref_client; + $myobjectstatic->total_ht = $obj->total_ht; + $myobjectstatic->total_tva = $obj->total_tva; + $myobjectstatic->total_ttc = $obj->total_ttc; + + print $myobjectstatic->getNomUrl(1); + print ''; + print ''.price($obj->total_ttc).'
'.$langs->trans("Total").''.price($total)."
'.$langs->trans("NoOrder").'

"; + + $db->free($resql); + } + else + { + dol_print_error($db); + } +} +END MODULEBUILDER DRAFT MYOBJECT */ + + +print '
'; + + +/* BEGIN MODULEBUILDER LASTMODIFIED MYOBJECT +// Last modified myobject +if (isModEnabled('subtotaltitle') && $user->hasRight('subtotaltitle', 'read')) { + $sql = "SELECT s.rowid, s.ref, s.label, s.date_creation, s.tms"; + $sql.= " FROM ".$db->prefix()."subtotaltitle_myobject as s"; + $sql.= " WHERE s.entity IN (".getEntity($myobjectstatic->element).")"; + //if ($socid) $sql.= " AND s.rowid = $socid"; + $sql .= " ORDER BY s.tms DESC"; + $sql .= $db->plimit($max, 0); + + $resql = $db->query($sql); + if ($resql) + { + $num = $db->num_rows($resql); + $i = 0; + + print ''; + print ''; + print ''; + print ''; + print ''; + if ($num) + { + while ($i < $num) + { + $objp = $db->fetch_object($resql); + + $myobjectstatic->id=$objp->rowid; + $myobjectstatic->ref=$objp->ref; + $myobjectstatic->label=$objp->label; + $myobjectstatic->status = $objp->status; + + print ''; + print ''; + print '"; + print '"; + print ''; + $i++; + } + + $db->free($resql); + } else { + print ''; + } + print "
'; + print $langs->trans("BoxTitleLatestModifiedMyObjects", $max); + print ''.$langs->trans("DateModificationShort").'
'.$myobjectstatic->getNomUrl(1).''; + print "'.dol_print_date($db->jdate($objp->tms), 'day')."
'.$langs->trans("None").'

"; + } +} +*/ + +print '
'; + +// End of page +llxFooter(); +$db->close();